wip deepseek

This commit is contained in:
2026-07-07 16:42:42 +02:00
parent 00c67dd66a
commit 9555423f51
12 changed files with 2871 additions and 13 deletions
+582
View File
@@ -0,0 +1,582 @@
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);
}
}
}