Files
AnotherReplayReader/Utils/AiKnowledge.cs
T
2026-08-22 00:59:46 +02:00

749 lines
32 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
namespace AnotherReplayReader.Utils
{
/// <summary>
/// Predefined tag taxonomy for KnowledgeEntry.
/// Tags bridge prompt rendering and validation logic.
/// </summary>
internal static class KnowledgeTag
{
// ── Capability tags (what a unit can do) ──────────────────────
public const string Builder = "builder";
public const string Pack = "pack";
public const string Unpack = "unpack";
public const string Amphibious = "amphibious";
public const string Transport = "transport";
public const string ReturnToProducer = "returnToProducer";
public const string Cloak = "cloak";
public const string ToggleWeapon = "toggleWeapon";
// ── Type tags (what a unit is) ────────────────────────────────
public const string Infantry = "infantry";
public const string Vehicle = "vehicle";
public const string Aircraft = "aircraft";
public const string Naval = "naval";
public const string Structure = "structure";
public const string Hero = "hero";
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";
public const string AntiVehicle = "antiVehicle";
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}";
}
/// <summary>
/// The scope kind of a knowledge entry within a KnowledgeSet.
/// Scope is determined by the entry's position in the hierarchy,
/// not stored in the entry itself.
/// </summary>
internal enum KnowledgeScopeKind
{
Global,
Faction,
Map
}
/// <summary>
/// Identifies which scope a knowledge entry belongs to.
/// </summary>
internal sealed record KnowledgeScope(KnowledgeScopeKind Kind, string? Name = null)
{
public static KnowledgeScope Global { get; } = new(KnowledgeScopeKind.Global);
public static KnowledgeScope Faction(string name) => new(KnowledgeScopeKind.Faction, name);
public static KnowledgeScope Map(string id) => new(KnowledgeScopeKind.Map, id);
}
/// <summary>
/// The smallest reusable unit of game knowledge.
/// Id is unique within a knowledge set; tags enable validation queries;
/// text is the markdown description used for prompt rendering.
/// </summary>
internal sealed record KnowledgeEntry(
string Id,
ImmutableArray<string> Tags,
string Text)
{
public bool HasTag(string tag) => Tags.Contains(tag, StringComparer.OrdinalIgnoreCase);
public bool HasAnyTag(params string[] tags) => tags.Any(HasTag);
}
// ── Structured game entity knowledge ───────────────────────────────
/// <summary>A special power with its observable name and description.</summary>
internal sealed record SpecialPowerInfo(string Name, string Description);
/// <summary>
/// Structured knowledge about a game entity (unit or building).
/// Buildings omit <see cref="Tier"/> and <see cref="ProducedBy"/>;
/// the <c>structure</c> tag distinguishes them from units.
/// </summary>
internal sealed record EntityKnowledge(
string AssetName,
string DisplayName,
string Faction,
string? Tier,
ImmutableArray<string> Tags,
ImmutableArray<SpecialPowerInfo> SpecialPowers,
ImmutableArray<string> ProducedBy,
ImmutableArray<string> Aliases,
string Text)
{
public bool IsBuilding => HasTag(KnowledgeTag.Structure);
public bool IsUnit => !IsBuilding;
public bool HasTag(string tag) => Tags.Contains(tag, StringComparer.OrdinalIgnoreCase);
public bool HasSpecialPower(string name) =>
SpecialPowers.Any(sp => string.Equals(sp.Name, name, StringComparison.OrdinalIgnoreCase));
}
/// <summary>
/// In-memory index of structured game knowledge loaded from knowledge_units.json.
/// Used by validation to query unit capabilities deterministically.
/// </summary>
internal sealed class StructuredKnowledge
{
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,
ImmutableArray<string> unknownTags)
{
EntitiesByAssetName = byAsset;
AllEntities = allEntities;
EntitiesByFaction = byFaction;
UnknownTags = unknownTags;
}
// ── Query helpers ────────────────────────────────────────────
public ImmutableArray<EntityKnowledge> EntitiesWithTag(string tag) =>
AllEntities.Where(e => e.HasTag(tag)).ToImmutableArray();
public ImmutableArray<EntityKnowledge> EntitiesWithSpecialPower(string power) =>
AllEntities.Where(e => e.HasSpecialPower(power)).ToImmutableArray();
public EntityKnowledge? GetEntity(string? assetName) =>
assetName is not null && EntitiesByAssetName.TryGetValue(assetName, out var e) ? e : null;
public bool IsKnownBuilder(string? assetName) =>
GetEntity(assetName)?.HasTag(KnowledgeTag.Builder) == true;
public bool EntityHasSpecialPower(string? assetName, string? powerName) =>
assetName is not null && powerName is not null &&
GetEntity(assetName)?.HasSpecialPower(powerName) == true;
// ── Factory ──────────────────────────────────────────────────
private static readonly Lazy<StructuredKnowledge?> _lazyDefault = new(() => GetForMod("default"));
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(string modName)
{
var path = Path.Combine(AppContext.BaseDirectory, $"knowledge_units_{modName}.json");
if (!File.Exists(path)) return null;
try
{
var builtin = ParseFactions(path, out var unknownTags);
if (builtin is null)
{
return null;
}
// 用户知识覆盖: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 kv in user)
{
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 byAsset = new Dictionary<string, EntityKnowledge>(StringComparer.OrdinalIgnoreCase);
foreach (var entity in allEntities)
{
byAsset[entity.AssetName] = entity;
foreach (var alias in entity.Aliases)
{
byAsset[alias] = entity;
}
}
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(
byAsset.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase),
allEntities,
byFaction,
unknownTagsArray);
}
catch (Exception ex)
{
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 producedBy = GetStringArray(el, "producedBy");
var alsoProducedBy = GetStringArray(el, "alsoProducedBy");
var combinedProducedBy = producedBy
.Concat(alsoProducedBy)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
var ek = new EntityKnowledge(
assetName,
GetString(el, "displayName") ?? "",
factionName,
isBuilding ? null : GetString(el, "tier"),
tags,
ParseSpecialPowers(el),
isBuilding ? ImmutableArray<string>.Empty : combinedProducedBy,
GetStringArray(el, "aliases"),
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()
: null;
private static ImmutableArray<string> GetStringArray(JsonElement el, string prop)
{
if (!el.TryGetProperty(prop, out var arr) || arr.ValueKind != JsonValueKind.Array)
return ImmutableArray<string>.Empty;
var result = new List<string>();
foreach (var item in arr.EnumerateArray())
{
if (item.ValueKind == JsonValueKind.String && item.GetString() is { } s)
result.Add(s);
}
return result.ToImmutableArray();
}
private static ImmutableArray<SpecialPowerInfo> ParseSpecialPowers(JsonElement el)
{
if (!el.TryGetProperty("specialPowers", out var arr) || arr.ValueKind != JsonValueKind.Array)
return ImmutableArray<SpecialPowerInfo>.Empty;
var result = new List<SpecialPowerInfo>();
foreach (var item in arr.EnumerateArray())
{
if (item.ValueKind != JsonValueKind.Object) continue;
var name = GetString(item, "name") ?? "";
var desc = GetString(item, "description") ?? "";
if (!string.IsNullOrWhiteSpace(name))
result.Add(new SpecialPowerInfo(name, desc));
}
return result.ToImmutableArray();
}
}
/// <summary>
/// A named collection of game knowledge entries for a specific game version (mod).
/// Entries are organized by scope; the set is self-contained and complete for its mod.
/// </summary>
internal sealed class KnowledgeSet
{
private readonly ImmutableArray<(KnowledgeScope Scope, KnowledgeEntry Entry)> _entries;
public KnowledgeSet(IEnumerable<(KnowledgeScope Scope, KnowledgeEntry Entry)> entries)
{
_entries = entries.ToImmutableArray();
}
// ── Query helpers ────────────────────────────────────────────
public ImmutableArray<KnowledgeEntry> ByScope(KnowledgeScopeKind kind, string? name = null) =>
_entries
.Where(e => e.Scope.Kind == kind
&& (name is null || string.Equals(e.Scope.Name, name, StringComparison.OrdinalIgnoreCase)))
.Select(e => e.Entry)
.ToImmutableArray();
public ImmutableArray<KnowledgeEntry> ByTag(string tag) =>
_entries
.Where(e => e.Entry.HasTag(tag))
.Select(e => e.Entry)
.ToImmutableArray();
public ImmutableArray<KnowledgeEntry> ByAnyTag(params string[] tags) =>
_entries
.Where(e => e.Entry.HasAnyTag(tags))
.Select(e => e.Entry)
.ToImmutableArray();
// ── Prompt rendering ─────────────────────────────────────────
public string RenderAsPrompt(IReadOnlyList<string> factionNames, string? mapId)
{
var sb = new StringBuilder();
foreach (var entry in ByScope(KnowledgeScopeKind.Global))
{
var text = entry.Id.StartsWith("knowledge-text-", StringComparison.Ordinal)
|| entry.Id.StartsWith("knowledge-file-", StringComparison.Ordinal)
? FilterFlatTextByFactions(entry.Text, factionNames)
: entry.Text;
sb.AppendLine(text.Trim());
sb.AppendLine();
}
foreach (var faction in factionNames)
{
foreach (var entry in ByScope(KnowledgeScopeKind.Faction, faction))
{
sb.AppendLine(entry.Text.Trim());
sb.AppendLine();
}
}
if (mapId is not null)
{
foreach (var entry in ByScope(KnowledgeScopeKind.Map, mapId))
{
sb.AppendLine(entry.Text.Trim());
sb.AppendLine();
}
}
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>
/// Create a built-in KnowledgeSet for a given mod name.
/// Loads text from <c>knowledge_{modName}.md</c> and structured entries
/// from <c>knowledge_units.json</c>. The flat text's unit/building
/// sections are stripped and replaced by structured entries for rendering.
/// </summary>
public static KnowledgeSet ForMod(string modName, string? baseDirectory = null)
{
var searchDir = baseDirectory ?? AppContext.BaseDirectory;
var entries = new List<(KnowledgeScope Scope, KnowledgeEntry Entry)>();
var structured = StructuredKnowledge.GetForMod(modName);
// 1. Load flat text from knowledge_{modName}.md
var flatPath = Path.Combine(searchDir, $"knowledge_{modName}.md");
string? flatText = null;
if (File.Exists(flatPath))
{
flatText = File.ReadAllText(flatPath, Encoding.UTF8);
}
// 2. If structured data is available, strip unit sections from flat text
// and add structured entries as faction-scoped KnowledgeEntry.
if (structured is not null && flatText is not null)
{
var cleaned = StripUnitSections(flatText, structured);
entries.Add((KnowledgeScope.Global, new KnowledgeEntry(
$"knowledge-text-{modName}",
ImmutableArray.Create("rule"),
cleaned)));
foreach (var kv in structured.EntitiesByFaction)
{
var factionName = kv.Key;
var sb = new StringBuilder();
sb.AppendLine($"# {factionName}");
// Buildings (entities with structure tag)
var bldgs = kv.Value
.Where(e => e.IsBuilding)
.ToImmutableArray();
if (!bldgs.IsEmpty)
{
sb.AppendLine("## 建筑与升级");
foreach (var b in bldgs)
{
sb.Append("- ");
sb.Append(b.DisplayName);
sb.Append('(');
sb.Append(b.AssetName);
sb.Append(")");
sb.AppendLine(b.Text.Trim());
}
sb.AppendLine();
}
// Units (entities without structure tag) by tier
var units = kv.Value
.Where(e => e.IsUnit)
.GroupBy(u => u.Tier ?? "")
.OrderBy(g => TierOrder(g.Key));
foreach (var tier in units)
{
var label = tier.Key switch
{
"基础" => "基础单位",
"T2" => "T2 单位(需要T2升级)",
"T3" => "T3 单位(需要T3升级)",
"T4" => "T4 单位(需要T4升级)",
_ => tier.Key,
};
sb.AppendLine($"## {label}");
foreach (var unit in tier)
{
sb.Append("- ");
sb.Append(unit.DisplayName);
sb.Append('(');
sb.Append(unit.AssetName);
sb.AppendLine(")");
// Type info from tags
var typeTags = unit.Tags
.Where(t => t is "vehicle" or "infantry" or "aircraft" or "naval" or "structure" or "hero" or "amphibious")
.Select(TagDisplayName);
if (typeTags.Any())
{
sb.Append(" - 类型: ");
sb.AppendLine(string.Join("、", typeTags));
}
// Special powers
if (!unit.SpecialPowers.IsEmpty)
{
foreach (var sp in unit.SpecialPowers)
{
sb.Append(" - 技能: ");
sb.Append(sp.Name);
if (!string.IsNullOrWhiteSpace(sp.Description))
{
sb.Append(" — ");
sb.Append(sp.Description);
}
sb.AppendLine();
}
}
// Produced by
if (!unit.ProducedBy.IsEmpty)
{
var producerNames = unit.ProducedBy
.Select(name => structured.GetDisplayName(name) ?? name)
.ToImmutableArray();
sb.Append(" - 生产: ");
sb.AppendLine(string.Join("、", producerNames));
}
// Remaining description
if (!string.IsNullOrWhiteSpace(unit.Text))
{
sb.Append(" - 描述: ");
sb.AppendLine(unit.Text.Trim());
}
}
sb.AppendLine();
}
entries.Add((KnowledgeScope.Faction(factionName), new KnowledgeEntry(
$"structured-{factionName}",
ImmutableArray.Create("faction", "structured"),
sb.ToString().TrimEnd())));
}
}
else if (flatText is not null)
{
entries.Add((KnowledgeScope.Global, new KnowledgeEntry(
$"knowledge-file-{modName}",
ImmutableArray.Create("rule"),
flatText)));
}
else
{
entries.Add((KnowledgeScope.Global, new KnowledgeEntry(
"knowledge-unavailable",
ImmutableArray.Create("rule"),
$"# 注意\n\n游戏知识文件 knowledge_{modName}.md 未找到。\n\n")));
}
return new KnowledgeSet(entries);
}
private static int TierOrder(string tier) => tier switch
{
"基础" => 0,
"T2" => 1,
"T3" => 2,
"T4" => 3,
_ => 99,
};
private static string TagDisplayName(string tag) => tag switch
{
"vehicle" => "载具",
"infantry" => "步兵",
"aircraft" => "飞行器",
"naval" => "海军",
"structure" => "建筑",
"hero" => "英雄",
"amphibious" => "两栖",
_ => tag,
};
/// <summary>
/// Strip unit and building sections from flat text for factions that
/// have structured data, to avoid duplication when rendering.
/// </summary>
private static string StripUnitSections(string flatText, StructuredKnowledge structured)
{
var stripMarkers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var factionName in structured.EntitiesByFaction.Keys)
{
var (buildingHeader, nextHeader) = factionName switch
{
"盟军" => ("盟军常用建筑与升级", "盟军常用开局"),
"神州" => ("神州常用建筑", "神州常用开局"),
_ => ((string?)null, (string?)null),
};
if (buildingHeader is not null && nextHeader is not null)
{
stripMarkers[buildingHeader] = nextHeader;
}
}
if (stripMarkers.Count == 0) return flatText;
var lines = flatText.Replace("\r", "").Split('\n');
var result = new List<string>();
var skipping = false;
var currentStopMarker = (string?)null;
foreach (var line in lines)
{
var trimmed = line.TrimStart();
if (skipping)
{
if (currentStopMarker is not null &&
trimmed.StartsWith(currentStopMarker, StringComparison.OrdinalIgnoreCase))
{
skipping = false;
result.Add(line);
}
continue;
}
var matched = stripMarkers.Keys.FirstOrDefault(
m => trimmed.StartsWith(m, StringComparison.OrdinalIgnoreCase));
if (matched is not null)
{
skipping = true;
currentStopMarker = stripMarkers[matched];
continue;
}
result.Add(line);
}
return string.Join("\n", result);
}
public static KnowledgeSet ForReplay(ReplayFile.Replay replay, string? baseDirectory = null)
{
var modName = replay.Mod.ModName?.ToLowerInvariant() switch
{
"corona" => "corona",
_ => "default",
};
return ForMod(modName, baseDirectory);
}
}
}