ai plan v2
This commit is contained in:
+209
-61
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.IO;
|
||||
@@ -34,6 +35,9 @@ namespace AnotherReplayReader.Utils
|
||||
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";
|
||||
@@ -41,6 +45,25 @@ namespace AnotherReplayReader.Utils
|
||||
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}";
|
||||
@@ -118,15 +141,18 @@ namespace AnotherReplayReader.Utils
|
||||
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)
|
||||
ImmutableDictionary<string, ImmutableArray<EntityKnowledge>> byFaction,
|
||||
ImmutableArray<string> unknownTags)
|
||||
{
|
||||
EntitiesByAssetName = byAsset;
|
||||
AllEntities = allEntities;
|
||||
EntitiesByFaction = byFaction;
|
||||
UnknownTags = unknownTags;
|
||||
}
|
||||
|
||||
// ── Query helpers ────────────────────────────────────────────
|
||||
@@ -149,92 +175,168 @@ namespace AnotherReplayReader.Utils
|
||||
|
||||
// ── Factory ──────────────────────────────────────────────────
|
||||
|
||||
private static readonly Lazy<StructuredKnowledge?> _lazyInstance = new(() => LoadFromFile());
|
||||
private static readonly Lazy<StructuredKnowledge?> _lazyDefault = new(() => GetForMod("default"));
|
||||
|
||||
public static StructuredKnowledge? Instance => _lazyInstance.Value;
|
||||
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()
|
||||
private static StructuredKnowledge? LoadFromFile(string modName)
|
||||
{
|
||||
var path = Path.Combine(AppContext.BaseDirectory, "knowledge_units.json");
|
||||
var path = Path.Combine(AppContext.BaseDirectory, $"knowledge_units_{modName}.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 builtin = ParseFactions(path, out var unknownTags);
|
||||
if (builtin is null)
|
||||
{
|
||||
var factionName = faction.Name;
|
||||
if (!byFaction.ContainsKey(factionName))
|
||||
byFaction[factionName] = new List<EntityKnowledge>();
|
||||
return null;
|
||||
}
|
||||
|
||||
// Buildings
|
||||
if (faction.Value.TryGetProperty("buildings", out var bldgs))
|
||||
// 用户知识覆盖: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 b in bldgs.EnumerateArray())
|
||||
foreach (var kv in user)
|
||||
{
|
||||
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);
|
||||
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 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.ToImmutableArray(),
|
||||
byFaction.ToImmutableDictionary(
|
||||
kv => kv.Key,
|
||||
kv => kv.Value.ToImmutableArray(),
|
||||
StringComparer.OrdinalIgnoreCase));
|
||||
allEntities,
|
||||
byFaction,
|
||||
unknownTagsArray);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"[AiKnowledge] Failed to load knowledge_units.json: {ex.Message}");
|
||||
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 ek = new EntityKnowledge(
|
||||
assetName,
|
||||
GetString(el, "displayName") ?? "",
|
||||
factionName,
|
||||
isBuilding ? null : GetString(el, "tier"),
|
||||
tags,
|
||||
ParseSpecialPowers(el),
|
||||
isBuilding ? ImmutableArray<string>.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()
|
||||
@@ -312,7 +414,10 @@ namespace AnotherReplayReader.Utils
|
||||
|
||||
foreach (var entry in ByScope(KnowledgeScopeKind.Global))
|
||||
{
|
||||
sb.AppendLine(entry.Text.Trim());
|
||||
var text = entry.Id.StartsWith("knowledge-text-", StringComparison.Ordinal)
|
||||
? FilterFlatTextByFactions(entry.Text, factionNames)
|
||||
: entry.Text;
|
||||
sb.AppendLine(text.Trim());
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
@@ -337,6 +442,49 @@ namespace AnotherReplayReader.Utils
|
||||
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>
|
||||
@@ -349,7 +497,7 @@ namespace AnotherReplayReader.Utils
|
||||
{
|
||||
var searchDir = baseDirectory ?? AppContext.BaseDirectory;
|
||||
var entries = new List<(KnowledgeScope Scope, KnowledgeEntry Entry)>();
|
||||
var structured = StructuredKnowledge.Instance;
|
||||
var structured = StructuredKnowledge.GetForMod(modName);
|
||||
|
||||
// 1. Load flat text from knowledge_{modName}.md
|
||||
var flatPath = Path.Combine(searchDir, $"knowledge_{modName}.md");
|
||||
|
||||
Reference in New Issue
Block a user