ai plan v2

This commit is contained in:
2026-08-21 15:34:24 +02:00
parent 9555423f51
commit d06a93f759
20 changed files with 3093 additions and 523 deletions
+209 -61
View File
@@ -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");