749 lines
32 KiB
C#
749 lines
32 KiB
C#
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);
|
||
}
|
||
}
|
||
}
|