583 lines
24 KiB
C#
583 lines
24 KiB
C#
using System;
|
||
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";
|
||
|
||
// ── 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";
|
||
|
||
/// <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,
|
||
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; }
|
||
|
||
private StructuredKnowledge(
|
||
ImmutableDictionary<string, EntityKnowledge> byAsset,
|
||
ImmutableArray<EntityKnowledge> allEntities,
|
||
ImmutableDictionary<string, ImmutableArray<EntityKnowledge>> byFaction)
|
||
{
|
||
EntitiesByAssetName = byAsset;
|
||
AllEntities = allEntities;
|
||
EntitiesByFaction = byFaction;
|
||
}
|
||
|
||
// ── 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?> _lazyInstance = new(() => LoadFromFile());
|
||
|
||
public static StructuredKnowledge? Instance => _lazyInstance.Value;
|
||
|
||
/// <summary>Look up the display name for an asset name.</summary>
|
||
public string? GetDisplayName(string? assetName) =>
|
||
GetEntity(assetName)?.DisplayName;
|
||
|
||
private static StructuredKnowledge? LoadFromFile()
|
||
{
|
||
var path = Path.Combine(AppContext.BaseDirectory, "knowledge_units.json");
|
||
if (!File.Exists(path)) return null;
|
||
|
||
try
|
||
{
|
||
var json = File.ReadAllText(path, Encoding.UTF8);
|
||
using var doc = JsonDocument.Parse(json);
|
||
var root = doc.RootElement;
|
||
|
||
if (!root.TryGetProperty("factions", out var factions))
|
||
return null;
|
||
|
||
var allEntities = new List<EntityKnowledge>();
|
||
var byFaction = new Dictionary<string, List<EntityKnowledge>>(StringComparer.OrdinalIgnoreCase);
|
||
|
||
foreach (var faction in factions.EnumerateObject())
|
||
{
|
||
var factionName = faction.Name;
|
||
if (!byFaction.ContainsKey(factionName))
|
||
byFaction[factionName] = new List<EntityKnowledge>();
|
||
|
||
// Buildings
|
||
if (faction.Value.TryGetProperty("buildings", out var bldgs))
|
||
{
|
||
foreach (var b in bldgs.EnumerateArray())
|
||
{
|
||
var ek = new EntityKnowledge(
|
||
GetString(b, "assetName") ?? "unknown",
|
||
GetString(b, "displayName") ?? "",
|
||
factionName,
|
||
Tier: null,
|
||
GetStringArray(b, "tags"),
|
||
ParseSpecialPowers(b),
|
||
ProducedBy: ImmutableArray<string>.Empty,
|
||
GetString(b, "text") ?? "");
|
||
allEntities.Add(ek);
|
||
byFaction[factionName].Add(ek);
|
||
}
|
||
}
|
||
|
||
// Units
|
||
if (faction.Value.TryGetProperty("units", out var units))
|
||
{
|
||
foreach (var u in units.EnumerateArray())
|
||
{
|
||
var assetName = GetString(u, "assetName") ?? "unknown";
|
||
var ek = new EntityKnowledge(
|
||
assetName,
|
||
GetString(u, "displayName") ?? "",
|
||
factionName,
|
||
GetString(u, "tier"),
|
||
GetStringArray(u, "tags"),
|
||
ParseSpecialPowers(u),
|
||
GetStringArray(u, "producedBy"),
|
||
GetString(u, "text") ?? "");
|
||
allEntities.Add(ek);
|
||
byFaction[factionName].Add(ek);
|
||
}
|
||
}
|
||
}
|
||
|
||
return new StructuredKnowledge(
|
||
allEntities.ToImmutableDictionary(e => e.AssetName, e => e, StringComparer.OrdinalIgnoreCase),
|
||
allEntities.ToImmutableArray(),
|
||
byFaction.ToImmutableDictionary(
|
||
kv => kv.Key,
|
||
kv => kv.Value.ToImmutableArray(),
|
||
StringComparer.OrdinalIgnoreCase));
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
System.Diagnostics.Debug.WriteLine($"[AiKnowledge] Failed to load knowledge_units.json: {ex.Message}");
|
||
return null;
|
||
}
|
||
}
|
||
|
||
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))
|
||
{
|
||
sb.AppendLine(entry.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", "");
|
||
}
|
||
|
||
// ── 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.Instance;
|
||
|
||
// 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);
|
||
}
|
||
}
|
||
}
|