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 { /// /// Predefined tag taxonomy for KnowledgeEntry. /// Tags bridge prompt rendering and validation logic. /// 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"; /// 完整标签集合:加载 JSON 时校验未知 tag 用。 public static readonly ImmutableArray 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); /// Create a special power reference tag. public static string SpecialPower(string powerName) => $"specialPower:{powerName}"; } /// /// 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. /// internal enum KnowledgeScopeKind { Global, Faction, Map } /// /// Identifies which scope a knowledge entry belongs to. /// 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); } /// /// 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. /// internal sealed record KnowledgeEntry( string Id, ImmutableArray 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 ─────────────────────────────── /// A special power with its observable name and description. internal sealed record SpecialPowerInfo(string Name, string Description); /// /// Structured knowledge about a game entity (unit or building). /// Buildings omit and ; /// the structure tag distinguishes them from units. /// internal sealed record EntityKnowledge( string AssetName, string DisplayName, string Faction, string? Tier, ImmutableArray Tags, ImmutableArray SpecialPowers, ImmutableArray ProducedBy, 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)); } /// /// In-memory index of structured game knowledge loaded from knowledge_units.json. /// Used by validation to query unit capabilities deterministically. /// internal sealed class StructuredKnowledge { public ImmutableDictionary EntitiesByAssetName { get; } public ImmutableArray AllEntities { get; } public ImmutableDictionary> EntitiesByFaction { get; } public ImmutableArray UnknownTags { get; } private StructuredKnowledge( ImmutableDictionary byAsset, ImmutableArray allEntities, ImmutableDictionary> byFaction, ImmutableArray unknownTags) { EntitiesByAssetName = byAsset; AllEntities = allEntities; EntitiesByFaction = byFaction; UnknownTags = unknownTags; } // ── Query helpers ──────────────────────────────────────────── public ImmutableArray EntitiesWithTag(string tag) => AllEntities.Where(e => e.HasTag(tag)).ToImmutableArray(); public ImmutableArray 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 _lazyDefault = new(() => GetForMod("default")); public static StructuredKnowledge? Instance => _lazyDefault.Value; private static readonly ConcurrentDictionary _cache = new(StringComparer.OrdinalIgnoreCase); /// 按 mod 加载结构化知识;文件不存在返回 null(该 mod 无结构化数据)。 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; } /// Look up the display name for an asset name. 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(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, byFaction, unknownTagsArray); } catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"[AiKnowledge] Failed to load knowledge_units_{modName}.json: {ex.Message}"); return null; } } /// /// 解析知识 JSON 的 factions 结构,返回 faction → (assetName → EntityKnowledge); /// 同时收集未知标签。 /// private static Dictionary>? ParseFactions( string path, out HashSet unknownTags) { var unknownTagsLocal = new HashSet(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>(StringComparer.OrdinalIgnoreCase); foreach (var faction in factions.EnumerateObject()) { var factionName = faction.Name; if (!result.TryGetValue(factionName, out var entries)) { entries = new Dictionary(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.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() : null; private static ImmutableArray GetStringArray(JsonElement el, string prop) { if (!el.TryGetProperty(prop, out var arr) || arr.ValueKind != JsonValueKind.Array) return ImmutableArray.Empty; var result = new List(); foreach (var item in arr.EnumerateArray()) { if (item.ValueKind == JsonValueKind.String && item.GetString() is { } s) result.Add(s); } return result.ToImmutableArray(); } private static ImmutableArray ParseSpecialPowers(JsonElement el) { if (!el.TryGetProperty("specialPowers", out var arr) || arr.ValueKind != JsonValueKind.Array) return ImmutableArray.Empty; var result = new List(); 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(); } } /// /// 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. /// 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 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 ByTag(string tag) => _entries .Where(e => e.Entry.HasTag(tag)) .Select(e => e.Entry) .ToImmutableArray(); public ImmutableArray ByAnyTag(params string[] tags) => _entries .Where(e => e.Entry.HasAnyTag(tags)) .Select(e => e.Entry) .ToImmutableArray(); // ── Prompt rendering ───────────────────────────────────────── public string RenderAsPrompt(IReadOnlyList factionNames, string? mapId) { var sb = new StringBuilder(); foreach (var entry in ByScope(KnowledgeScopeKind.Global)) { var text = entry.Id.StartsWith("knowledge-text-", 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 = { ("盟军", "盟军常用建筑与升级"), ("神州", "神州常用建筑"), }; /// flat 文本按参战阵营过滤:只保留全局部分与参战阵营的章节,减少 token 浪费。 private static string FilterFlatTextByFactions(string text, IReadOnlyList factionNames) { if (factionNames.Count == 0) { return text; } var participating = new HashSet(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 ───────────────────────────────────────── /// /// Create a built-in KnowledgeSet for a given mod name. /// Loads text from knowledge_{modName}.md and structured entries /// from knowledge_units.json. The flat text's unit/building /// sections are stripped and replaced by structured entries for rendering. /// 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, }; /// /// Strip unit and building sections from flat text for factions that /// have structured data, to avoid duplication when rendering. /// private static string StripUnitSections(string flatText, StructuredKnowledge structured) { var stripMarkers = new Dictionary(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(); 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); } } }