using System.Globalization; using System.Text; using System.Text.RegularExpressions; using static Shigure.LuaLiteParser; namespace Shigure; /// /// 读写 Fuyutsui class/*.lua 中的 ClassBlocks(states/auras/spells/items/group), /// 同时读写 spellsList 与 itemsList;保存时替换 ClassBlocks 表字面量,并原位更新列表条目。 /// internal static class ClassBlocksStore { public const string AssignmentName = "Fuyutsui.ClassBlocks"; public const string SpellsListAssignmentName = "Fuyutsui.spellsList"; public const string ItemsListAssignmentName = "Fuyutsui.itemsList"; private static readonly string[] StateCategories = [ ClassStateCatalog.CategoryState, ClassStateCatalog.CategoryResource, ClassStateCatalog.CategoryConfig, ClassStateCatalog.CategoryTarget, ClassStateCatalog.CategoryFocus, ClassStateCatalog.CategoryMouseover, ClassStateCatalog.CategoryPet, ClassStateCatalog.CategoryBoss1, ClassStateCatalog.CategoryBoss2, ClassStateCatalog.CategoryBoss3, ClassStateCatalog.CategoryBoss4, ClassStateCatalog.CategoryBoss5 ]; public sealed class ClassFileDocument { public string FilePath { get; set; } = string.Empty; public string SourceText { get; set; } = string.Empty; public int TableStart { get; set; } public int TableEndExclusive { get; set; } public Dictionary Specs { get; set; } = new(); public List SpellsList { get; set; } = new(); public HashSet DeletedSpellsListOriginalIds { get; } = new(); public List ItemsList { get; set; } = new(); public HashSet DeletedItemsListOriginalIds { get; } = new(); public bool IsModernFormat { get; set; } } public sealed class SpellsListEntry { public long SpellId { get; set; } public int Index { get; set; } public string Name { get; set; } = string.Empty; public long OriginalSpellId { get; set; } public int OriginalIndex { get; set; } public string OriginalName { get; set; } = string.Empty; } public sealed class ItemsListEntry { public long ItemId { get; set; } public string Name { get; set; } = string.Empty; public long OriginalItemId { get; set; } public string OriginalName { get; set; } = string.Empty; } public sealed class SpecBlocks { public bool NestedStates { get; set; } = true; public List FlatStates { get; } = new(); public Dictionary> CategorizedStates { get; } = new(StringComparer.Ordinal) { [ClassStateCatalog.CategoryState] = new List(), [ClassStateCatalog.CategoryResource] = new List(), [ClassStateCatalog.CategoryConfig] = new List(), [ClassStateCatalog.CategoryTarget] = new List(), [ClassStateCatalog.CategoryFocus] = new List(), [ClassStateCatalog.CategoryMouseover] = new List(), [ClassStateCatalog.CategoryPet] = new List(), [ClassStateCatalog.CategoryBoss1] = new List(), [ClassStateCatalog.CategoryBoss2] = new List(), [ClassStateCatalog.CategoryBoss3] = new List(), [ClassStateCatalog.CategoryBoss4] = new List(), [ClassStateCatalog.CategoryBoss5] = new List() }; public List PlayerAuras { get; } = new(); public List Items { get; } = new(); public List TargetHarmfulAuras { get; } = new(); public List TargetHelpfulAuras { get; } = new(); public List FocusHarmfulAuras { get; } = new(); public List FocusHelpfulAuras { get; } = new(); public List Spells { get; } = new(); public GroupBlocks? Group { get; set; } } public sealed class AuraEntry { public string Name { get; set; } = string.Empty; public long? SpellId { get; set; } public List SpellIds { get; } = new(); public int? MaxApps { get; set; } } public sealed class SpellEntry { public string Name { get; set; } = string.Empty; public long SpellId { get; set; } public bool Charge { get; set; } public int? MaxCharge { get; set; } public int? CastCount { get; set; } public bool ForcedKnown { get; set; } public bool InSpellBook { get; set; } } public sealed class GroupBlocks { public int Num { get; set; } = 5; public int? HealthPercent { get; set; } = 1; public int? Role { get; set; } = 2; public int? Dispel { get; set; } public List Auras { get; } = new(); } public sealed class GroupAuraEntry { public int Offset { get; set; } public string Name { get; set; } = string.Empty; public long? SpellId { get; set; } public List SpellIds { get; } = new(); } public static ClassFileDocument Load(string filePath) { var source = File.ReadAllText(filePath, Encoding.UTF8); if (!TryExtractAssignedTable(source, AssignmentName, out var table, out var start, out var end)) { throw new InvalidDataException($"{Path.GetFileName(filePath)} 中未找到 {AssignmentName}"); } var specs = new Dictionary(); var modern = false; foreach (var (key, value) in table.Entries) { if (key is not long specId || value is not TableValue specTable) { continue; } var spec = ParseSpec(specTable, out var specModern); modern |= specModern; specs[(int)specId] = spec; } var spellsList = ParseSpellsList(ExtractAssignedTable(source, SpellsListAssignmentName)); var itemsList = ParseItemsList(ExtractAssignedTable(source, ItemsListAssignmentName)); return new ClassFileDocument { FilePath = filePath, SourceText = source, TableStart = start, TableEndExclusive = end, Specs = specs, SpellsList = spellsList, ItemsList = itemsList, IsModernFormat = modern }; } private static List ParseSpellsList(TableValue? table) { var result = new List(); if (table is null) { return result; } foreach (var (key, value) in table.Entries) { if (key is not long spellId || value is not TableValue spell) { continue; } var indexValue = spell.GetNumber("index"); var name = spell.GetString("name")?.Trim(); if (indexValue is null || indexValue.Value <= 0 || indexValue.Value > int.MaxValue || indexValue.Value != Math.Truncate(indexValue.Value) || string.IsNullOrWhiteSpace(name)) { continue; } result.Add(new SpellsListEntry { SpellId = spellId, Index = (int)indexValue.Value, Name = name, OriginalSpellId = spellId, OriginalIndex = (int)indexValue.Value, OriginalName = name }); } return result; } private static List ParseItemsList(TableValue? table) { var result = new List(); if (table is null) { return result; } foreach (var (key, value) in table.Entries) { if (key is not long itemId || itemId <= 0 || value is not TableValue item) { continue; } var name = item.GetString("name")?.Trim(); if (string.IsNullOrWhiteSpace(name)) { continue; } result.Add(new ItemsListEntry { ItemId = itemId, Name = name, OriginalItemId = itemId, OriginalName = name }); } return result; } public static void Save(ClassFileDocument document) { if (!document.IsModernFormat) { throw new InvalidOperationException("当前文件仍是旧版稀疏索引 ClassBlocks,无法用图形编辑器保存。"); } var updated = UpdateSpellsListEntries( document.SourceText, document.SpellsList, document.DeletedSpellsListOriginalIds); updated = UpdateItemsListEntries( updated, document.ItemsList, document.DeletedItemsListOriginalIds); if (!TryExtractAssignedTable(updated, AssignmentName, out _, out var classBlocksStart, out var classBlocksEnd)) { throw new InvalidOperationException("保存前无法重新定位 ClassBlocks 表。"); } var serialized = SerializeClassBlocks(document.Specs); updated = updated[..classBlocksStart] + serialized + updated[classBlocksEnd..]; AtomicFile.WriteAllText(document.FilePath, updated, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); if (!TryExtractAssignedTable(updated, AssignmentName, out _, out var start, out var end)) { throw new InvalidOperationException("保存后无法重新定位 ClassBlocks 表。"); } document.SourceText = updated; document.TableStart = start; document.TableEndExclusive = end; foreach (var spell in document.SpellsList) { spell.OriginalSpellId = spell.SpellId; spell.OriginalIndex = spell.Index; spell.OriginalName = spell.Name; } document.DeletedSpellsListOriginalIds.Clear(); foreach (var item in document.ItemsList) { item.OriginalItemId = item.ItemId; item.OriginalName = item.Name; } document.DeletedItemsListOriginalIds.Clear(); } public sealed class ItemEntry { public long? ItemId { get; set; } public string Name { get; set; } = string.Empty; public bool IsEquipped { get; set; } } private static string UpdateSpellsListEntries( string source, IReadOnlyList entries, IReadOnlySet deletedOriginalIds) { var newEntries = entries.Where(entry => entry.OriginalSpellId == 0).ToArray(); var changedEntries = entries .Where(entry => entry.OriginalSpellId != 0 && (entry.SpellId != entry.OriginalSpellId || entry.Index != entry.OriginalIndex || !string.Equals(entry.Name, entry.OriginalName, StringComparison.Ordinal))) .ToDictionary(entry => entry.OriginalSpellId); if (changedEntries.Count == 0 && newEntries.Length == 0 && deletedOriginalIds.Count == 0) { return source; } if (!TryExtractAssignedTable(source, SpellsListAssignmentName, out _, out var tableStart, out var tableEnd)) { throw new InvalidOperationException($"当前文件中未找到 {SpellsListAssignmentName},无法保存技能列表。"); } var tableText = source[tableStart..tableEnd]; var updatedOriginalIds = new HashSet(); var deletedIdsFound = new HashSet(); var pattern = new Regex( """^(?[ \t]*\[[ \t]*)(?\d+)(?[ \t]*\][ \t]*=[ \t]*\{[ \t]*index[ \t]*=[ \t]*)(?\d+)(?[ \t]*,[ \t]*name[ \t]*=[ \t]*)(?"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')(?[^\r\n]*)(?\r?\n|$)""", RegexOptions.Multiline | RegexOptions.CultureInvariant); var updatedTable = pattern.Replace(tableText, match => { if (!long.TryParse(match.Groups["spellId"].Value, NumberStyles.None, CultureInfo.InvariantCulture, out var originalSpellId)) { return match.Value; } if (deletedOriginalIds.Contains(originalSpellId)) { deletedIdsFound.Add(originalSpellId); return string.Empty; } if (!changedEntries.TryGetValue(originalSpellId, out var entry)) { return match.Value; } updatedOriginalIds.Add(originalSpellId); var quotedName = match.Groups["name"].Value; var quote = quotedName[0]; return match.Groups["prefix"].Value + entry.SpellId.ToString(CultureInfo.InvariantCulture) + match.Groups["beforeIndex"].Value + entry.Index.ToString(CultureInfo.InvariantCulture) + match.Groups["beforeName"].Value + quote + EscapeLuaString(entry.Name, quote) + quote + match.Groups["suffix"].Value + match.Groups["lineEnd"].Value; }); var missing = changedEntries.Keys.Where(id => !updatedOriginalIds.Contains(id)).ToArray(); if (missing.Length > 0) { throw new InvalidOperationException( $"无法在 {SpellsListAssignmentName} 中定位法术 ID {string.Join(", ", missing)} 的原始条目。"); } var missingDeleted = deletedOriginalIds.Where(id => !deletedIdsFound.Contains(id)).ToArray(); if (missingDeleted.Length > 0) { throw new InvalidOperationException( $"无法在 {SpellsListAssignmentName} 中定位待删除的法术 ID {string.Join(", ", missingDeleted)}。"); } if (newEntries.Length > 0) { var newline = source.Contains("\r\n", StringComparison.Ordinal) ? "\r\n" : "\n"; var closingBraceIndex = updatedTable.Length - 1; var closingLineBreak = updatedTable.LastIndexOf('\n', Math.Max(0, closingBraceIndex - 1)); var insertionIndex = closingLineBreak >= 0 ? closingLineBreak + 1 : closingBraceIndex; var insertion = new StringBuilder(); if (closingLineBreak < 0) { insertion.Append(newline); } foreach (var entry in newEntries.OrderBy(entry => entry.Index)) { insertion.Append(" [") .Append(entry.SpellId.ToString(CultureInfo.InvariantCulture)) .Append("] = { index = ") .Append(entry.Index.ToString(CultureInfo.InvariantCulture)) .Append(", name = \"") .Append(EscapeLuaString(entry.Name, '"')) .Append("\" },") .Append(newline); } updatedTable = updatedTable.Insert(insertionIndex, insertion.ToString()); } return source[..tableStart] + updatedTable + source[tableEnd..]; } private static string UpdateItemsListEntries( string source, IReadOnlyList entries, IReadOnlySet deletedOriginalIds) { var newline = source.Contains("\r\n", StringComparison.Ordinal) ? "\r\n" : "\n"; var newEntries = entries.Where(entry => entry.OriginalItemId == 0).ToArray(); var changedEntries = entries .Where(entry => entry.OriginalItemId != 0 && (entry.ItemId != entry.OriginalItemId || !string.Equals(entry.Name, entry.OriginalName, StringComparison.Ordinal))) .ToDictionary(entry => entry.OriginalItemId); if (!TryExtractAssignedTable(source, ItemsListAssignmentName, out _, out var tableStart, out var tableEnd)) { if (!TryExtractAssignedTable(source, SpellsListAssignmentName, out _, out _, out var spellsEnd)) { throw new InvalidOperationException( $"当前文件中未找到 {SpellsListAssignmentName},无法写入物品列表。"); } return source[..spellsEnd] + newline + newline + SerializeItemsListTable(entries, newline) + source[spellsEnd..]; } if (changedEntries.Count == 0 && newEntries.Length == 0 && deletedOriginalIds.Count == 0) { return source; } var tableText = source[tableStart..tableEnd]; var updatedOriginalIds = new HashSet(); var deletedIdsFound = new HashSet(); var pattern = new Regex( """^(?[ \t]*\[[ \t]*)(?\d+)(?[ \t]*\][ \t]*=[ \t]*\{[ \t]*name[ \t]*=[ \t]*)(?"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')(?[^\r\n]*)(?\r?\n|$)""", RegexOptions.Multiline | RegexOptions.CultureInvariant); var updatedTable = pattern.Replace(tableText, match => { if (!long.TryParse(match.Groups["itemId"].Value, NumberStyles.None, CultureInfo.InvariantCulture, out var originalItemId)) { return match.Value; } if (deletedOriginalIds.Contains(originalItemId)) { deletedIdsFound.Add(originalItemId); return string.Empty; } if (!changedEntries.TryGetValue(originalItemId, out var entry)) { updatedOriginalIds.Add(originalItemId); return match.Value; } updatedOriginalIds.Add(originalItemId); var quotedName = match.Groups["name"].Value; var quote = quotedName[0]; return match.Groups["prefix"].Value + entry.ItemId.ToString(CultureInfo.InvariantCulture) + match.Groups["beforeName"].Value + quote + EscapeLuaString(entry.Name, quote) + quote + match.Groups["suffix"].Value + match.Groups["lineEnd"].Value; }); var missing = changedEntries.Keys.Where(id => !updatedOriginalIds.Contains(id)).ToArray(); if (missing.Length > 0) { throw new InvalidOperationException( $"无法在 {ItemsListAssignmentName} 中定位 itemId {string.Join(", ", missing)} 的原始条目。"); } var missingDeleted = deletedOriginalIds.Where(id => !deletedIdsFound.Contains(id)).ToArray(); if (missingDeleted.Length > 0) { throw new InvalidOperationException( $"无法在 {ItemsListAssignmentName} 中定位待删除的 itemId {string.Join(", ", missingDeleted)}。"); } if (newEntries.Length > 0) { var closingBraceIndex = updatedTable.Length - 1; var closingLineBreak = updatedTable.LastIndexOf('\n', Math.Max(0, closingBraceIndex - 1)); var insertionIndex = closingLineBreak >= 0 ? closingLineBreak + 1 : closingBraceIndex; var insertion = new StringBuilder(); if (closingLineBreak < 0) { insertion.Append(newline); } foreach (var entry in newEntries.OrderBy(entry => entry.ItemId)) { insertion.Append(" [") .Append(entry.ItemId.ToString(CultureInfo.InvariantCulture)) .Append("] = { name = \"") .Append(EscapeLuaString(entry.Name, '"')) .Append("\" },") .Append(newline); } updatedTable = updatedTable.Insert(insertionIndex, insertion.ToString()); } return source[..tableStart] + updatedTable + source[tableEnd..]; } private static string SerializeItemsListTable(IReadOnlyList entries, string newline) { var builder = new StringBuilder(); builder.Append(ItemsListAssignmentName).Append(" = {"); if (entries.Count == 0) { builder.Append('}'); return builder.ToString(); } builder.Append(newline); foreach (var entry in entries.OrderBy(item => item.ItemId)) { builder.Append(" [") .Append(entry.ItemId.ToString(CultureInfo.InvariantCulture)) .Append("] = { name = \"") .Append(EscapeLuaString(entry.Name, '"')) .Append("\" },") .Append(newline); } builder.Append('}'); return builder.ToString(); } private static string EscapeLuaString(string value, char quote) { var escaped = value .Replace("\\", "\\\\", StringComparison.Ordinal) .Replace("\r", "\\r", StringComparison.Ordinal) .Replace("\n", "\\n", StringComparison.Ordinal) .Replace("\t", "\\t", StringComparison.Ordinal); return quote == '\'' ? escaped.Replace("'", "\\'", StringComparison.Ordinal) : escaped.Replace("\"", "\\\"", StringComparison.Ordinal); } private static SpecBlocks ParseSpec(TableValue spec, out bool isModern) { var result = new SpecBlocks(); isModern = spec.GetTable("states") is not null || spec.GetTable("auras") is not null || spec.GetTable("spells") is not null || spec.GetTable("items") is not null || spec.GetTable("group") is not null; if (!isModern) { return result; } if (spec.GetTable("states") is { } states) { var nested = StateCategories.Any(category => states.GetTable(category) is not null); result.NestedStates = nested; if (nested) { foreach (var category in StateCategories) { if (states.GetTable(category) is not { } list) { continue; } var target = result.CategorizedStates[category]; foreach (var item in list.IPairs()) { if (item is StringValue name && !string.IsNullOrWhiteSpace(name.Value)) { target.Add(name.Value); } } } } else { foreach (var item in states.IPairs()) { if (item is StringValue name && !string.IsNullOrWhiteSpace(name.Value)) { result.FlatStates.Add(name.Value); } } } } if (spec.GetTable("auras") is { } auras) { var nested = auras.GetTable("player") is not null || auras.GetTable("target") is not null || auras.GetTable("focus") is not null; if (nested) { AppendAuraList(auras.GetTable("player"), result.PlayerAuras); if (auras.GetTable("target") is { } target) { AppendAuraList(target.GetTable("harmful"), result.TargetHarmfulAuras); AppendAuraList(target.GetTable("helpful"), result.TargetHelpfulAuras); } if (auras.GetTable("focus") is { } focus) { AppendAuraList(focus.GetTable("harmful"), result.FocusHarmfulAuras); AppendAuraList(focus.GetTable("helpful"), result.FocusHelpfulAuras); } } else { AppendAuraList(auras, result.PlayerAuras); } } if (spec.GetTable("spells") is { } spells) { foreach (var item in spells.IPairs()) { if (item is not TableValue spell) { continue; } var spellId = spell.GetNumber("spellId"); if (spellId is null) { continue; } result.Spells.Add(new SpellEntry { SpellId = (long)spellId.Value, Name = spell.GetString("name")?.Trim() ?? string.Empty, Charge = spell.GetBool("charge") == true, MaxCharge = spell.GetNumber("maxCharge") is { } maxCharge ? (int)maxCharge : null, CastCount = spell.GetNumber("castCount") is { } castCount ? (int)castCount : null, ForcedKnown = spell.GetBool("forcedKnown") == true, InSpellBook = spell.GetBool("inSpellBook") == true }); } } if (spec.GetTable("items") is { } items) { AppendItems(items, result.Items); } if (spec.GetTable("group") is { } group) { var groupBlocks = new GroupBlocks { Num = (int)(group.GetNumber("num") ?? 5), HealthPercent = group.GetNumber("healthPercent") is { } hp ? (int)hp : null, Role = group.GetNumber("role") is { } role ? (int)role : null, Dispel = group.GetNumber("dispel") is { } dispel ? (int)dispel : null }; if (group.GetTable("aura") is { } auraOffsets) { foreach (var (key, value) in auraOffsets.Entries) { if (key is not long offset || value is not TableValue auraInfo) { continue; } var entry = new GroupAuraEntry { Offset = (int)offset, Name = auraInfo.GetString("name")?.Trim() ?? string.Empty }; if (auraInfo.GetNumber("spellId") is { } sid) { entry.SpellId = (long)sid; } if (auraInfo.Get("spellIds") is TableValue ids) { foreach (var idItem in ids.IPairs()) { if (idItem is NumberValue n) { entry.SpellIds.Add(n.AsInt()); } } } groupBlocks.Auras.Add(entry); } groupBlocks.Auras.Sort((a, b) => a.Offset.CompareTo(b.Offset)); } result.Group = groupBlocks; } return result; } private static void AppendItems(TableValue list, List target) { var seenIds = new HashSet(); foreach (var (key, value) in list.Entries) { if (key is not long itemId || itemId <= 0 || value is not TableValue itemTable || !seenIds.Add(itemId)) { continue; } var name = itemTable.GetString("name")?.Trim(); if (string.IsNullOrWhiteSpace(name)) { seenIds.Remove(itemId); continue; } target.Add(new ItemEntry { ItemId = itemId, Name = name, IsEquipped = itemTable.GetBool("isEquipped") == true }); } target.Sort((left, right) => CompareItemIds(left.ItemId, right.ItemId)); } private static int CompareItemIds(long? left, long? right) => left switch { null when right is null => 0, null => 1, _ when right is null => -1, _ => left.Value.CompareTo(right.Value) }; private static void AppendAuraList(TableValue? list, List target) { if (list is null) { return; } foreach (var item in list.IPairs()) { if (item is not TableValue aura) { continue; } var entry = new AuraEntry { Name = aura.GetString("name")?.Trim() ?? string.Empty, MaxApps = aura.GetNumber("maxApps") is { } maxApps ? (int)maxApps : null }; if (aura.GetNumber("spellId") is { } sid) { entry.SpellId = (long)sid; } if (aura.Get("spellIds") is TableValue ids) { foreach (var idItem in ids.IPairs()) { if (idItem is NumberValue n) { entry.SpellIds.Add(n.AsInt()); } } } target.Add(entry); } } public static string SerializeClassBlocks(IReadOnlyDictionary specs) { var sb = new StringBuilder(); sb.AppendLine("{"); foreach (var specId in specs.Keys.OrderBy(x => x)) { sb.Append(" [").Append(specId).AppendLine("] = {"); WriteSpec(sb, specs[specId], " "); sb.AppendLine(" },"); } sb.Append('}'); return sb.ToString(); } private static void WriteSpec(StringBuilder sb, SpecBlocks spec, string indent) { ValidateItemsForSerialization(spec); // states sb.Append(indent).AppendLine("states = {"); if (spec.NestedStates) { foreach (var category in StateCategories) { var list = spec.CategorizedStates.GetValueOrDefault(category); if (list is null || list.Count == 0) { continue; } sb.Append(indent).Append(" [\"").Append(Escape(category)).AppendLine("\"] = {"); foreach (var name in list) { sb.Append(indent).Append(" \"").Append(Escape(name)).AppendLine("\","); } sb.Append(indent).AppendLine(" },"); } } else { foreach (var name in spec.FlatStates) { sb.Append(indent).Append(" \"").Append(Escape(name)).AppendLine("\","); } } sb.Append(indent).AppendLine("},"); // auras var hasAuras = spec.PlayerAuras.Count > 0 || spec.TargetHarmfulAuras.Count > 0 || spec.TargetHelpfulAuras.Count > 0 || spec.FocusHarmfulAuras.Count > 0 || spec.FocusHelpfulAuras.Count > 0; if (hasAuras) { sb.Append(indent).AppendLine("auras = {"); WriteAuraUnit(sb, "player", null, spec.PlayerAuras, indent + " "); WriteAuraSplitUnit(sb, "target", spec.TargetHarmfulAuras, spec.TargetHelpfulAuras, indent + " "); WriteAuraSplitUnit(sb, "focus", spec.FocusHarmfulAuras, spec.FocusHelpfulAuras, indent + " "); sb.Append(indent).AppendLine("},"); } // spells if (spec.Spells.Count > 0) { sb.Append(indent).AppendLine("spells = {"); foreach (var spell in spec.Spells) { sb.Append(indent).Append(" { spellId = ").Append(spell.SpellId); if (!string.IsNullOrWhiteSpace(spell.Name)) { sb.Append(", name = \"").Append(Escape(spell.Name)).Append('"'); } if (spell.Charge) { sb.Append(", charge = true"); } if (spell.MaxCharge is { } maxCharge) { sb.Append(", maxCharge = ").Append(maxCharge); } if (spell.CastCount is { } castCount) { sb.Append(", castCount = ").Append(castCount); } if (spell.ForcedKnown) { sb.Append(", forcedKnown = true"); } if (spell.InSpellBook) { sb.Append(", inSpellBook = true"); } sb.AppendLine(" },"); } sb.Append(indent).AppendLine("},"); } // items if (spec.Items.Count > 0) { sb.Append(indent).AppendLine("items = {"); foreach (var item in spec.Items.OrderBy(item => item.ItemId)) { if (item.ItemId is not { } itemId || itemId <= 0 || string.IsNullOrWhiteSpace(item.Name)) { throw new InvalidOperationException("物品配置包含无效的 itemId 或空名称。"); } sb.Append(indent).Append(" [").Append(itemId).Append("] = { name = \"") .Append(Escape(item.Name)).Append("\", isEquipped = ") .Append(item.IsEquipped ? "true" : "false").AppendLine(" },"); } sb.Append(indent).AppendLine("},"); } // group if (spec.Group is { } group) { sb.Append(indent).AppendLine("group = {"); sb.Append(indent).Append(" num = ").Append(group.Num).AppendLine(","); if (group.HealthPercent is { } hp) { sb.Append(indent).Append(" healthPercent = ").Append(hp).AppendLine(","); } if (group.Role is { } role) { sb.Append(indent).Append(" role = ").Append(role).AppendLine(","); } if (group.Dispel is { } dispel) { sb.Append(indent).Append(" dispel = ").Append(dispel).AppendLine(","); } if (group.Auras.Count > 0) { sb.Append(indent).AppendLine(" aura = {"); foreach (var aura in group.Auras.OrderBy(a => a.Offset)) { sb.Append(indent).Append(" [").Append(aura.Offset).Append("] = {"); if (!string.IsNullOrWhiteSpace(aura.Name)) { sb.Append(" name = \"").Append(Escape(aura.Name)).Append("\","); } WriteSpellIdFields(sb, aura.SpellId, aura.SpellIds); sb.AppendLine(" },"); } sb.Append(indent).AppendLine(" },"); } sb.Append(indent).AppendLine("},"); } } private static void ValidateItemsForSerialization(SpecBlocks spec) { if (spec.Items.Count == 0) { return; } var ids = new HashSet(); var names = new HashSet(StringComparer.Ordinal); var bareNames = new HashSet(StringComparer.Ordinal); if (spec.NestedStates) { foreach (var category in new[] { ClassStateCatalog.CategoryState, ClassStateCatalog.CategoryResource, ClassStateCatalog.CategoryConfig }) { bareNames.UnionWith(spec.CategorizedStates.GetValueOrDefault(category) ?? []); } } else { bareNames.UnionWith(spec.FlatStates); } foreach (var item in spec.Items) { if (item.ItemId is not { } itemId || itemId <= 0 || string.IsNullOrWhiteSpace(item.Name)) { throw new InvalidOperationException("物品配置包含无效的 itemId 或空名称。"); } if (!ids.Add(itemId)) { throw new InvalidOperationException($"物品 itemId {itemId} 重复。"); } if (!names.Add(item.Name)) { throw new InvalidOperationException($"物品名称“{item.Name}”重复。"); } if (bareNames.Contains(item.Name)) { throw new InvalidOperationException($"物品名称“{item.Name}”与状态、能量或配置开关字段重名。"); } } } private static void WriteAuraUnit(StringBuilder sb, string unit, string? filter, List list, string indent) { if (list.Count == 0) { return; } if (filter is null) { sb.Append(indent).Append(unit).AppendLine(" = {"); foreach (var aura in list) { WriteAuraEntry(sb, aura, indent + " "); } sb.Append(indent).AppendLine("},"); } } private static void WriteAuraSplitUnit( StringBuilder sb, string unit, List harmful, List helpful, string indent) { if (harmful.Count == 0 && helpful.Count == 0) { return; } sb.Append(indent).Append(unit).AppendLine(" = {"); if (harmful.Count > 0) { sb.Append(indent).AppendLine(" harmful = {"); foreach (var aura in harmful) { WriteAuraEntry(sb, aura, indent + " "); } sb.Append(indent).AppendLine(" },"); } if (helpful.Count > 0) { sb.Append(indent).AppendLine(" helpful = {"); foreach (var aura in helpful) { WriteAuraEntry(sb, aura, indent + " "); } sb.Append(indent).AppendLine(" },"); } sb.Append(indent).AppendLine("},"); } private static void WriteAuraEntry(StringBuilder sb, AuraEntry aura, string indent) { sb.Append(indent).Append("{"); if (!string.IsNullOrWhiteSpace(aura.Name)) { sb.Append(" name = \"").Append(Escape(aura.Name)).Append("\","); } WriteSpellIdFields(sb, aura.SpellId, aura.SpellIds); if (aura.MaxApps is { } maxApps) { sb.Append(" maxApps = ").Append(maxApps).Append(','); } sb.AppendLine(" },"); } private static void WriteSpellIdFields(StringBuilder sb, long? spellId, List spellIds) { if (spellIds.Count > 0) { sb.Append(" spellIds = { "); sb.Append(string.Join(", ", spellIds.Select(id => id.ToString(CultureInfo.InvariantCulture)))); sb.Append(" },"); } else if (spellId is { } id) { sb.Append(" spellId = ").Append(id).Append(','); } } private static string Escape(string value) => value.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("\"", "\\\"", StringComparison.Ordinal); }