From d78fed48c5c088f7f4ee1cd7e6411908ac29fb5c Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 19 Jan 2025 19:20:55 +0000 Subject: [PATCH] Item TplGenerator pass 1 --- Libraries/Core/Models/Enums/ItemTpl.cs | 2 +- Libraries/Core/Models/Enums/Weapons.cs | 6 + SptCommon/Extensions/StringExtensions.cs | 42 ++ Tools/ItemTplGenerator/Class1.cs | 5 - Tools/ItemTplGenerator/ItemTplGenerator.cs | 577 ++++++++++++++++++ .../ItemTplGenerator/ItemTplGenerator.csproj | 7 + .../ItemTplGeneratorLauncher.cs | 16 + 7 files changed, 649 insertions(+), 6 deletions(-) create mode 100644 Libraries/Core/Models/Enums/Weapons.cs create mode 100644 SptCommon/Extensions/StringExtensions.cs delete mode 100644 Tools/ItemTplGenerator/Class1.cs create mode 100644 Tools/ItemTplGenerator/ItemTplGenerator.cs create mode 100644 Tools/ItemTplGenerator/ItemTplGeneratorLauncher.cs diff --git a/Libraries/Core/Models/Enums/ItemTpl.cs b/Libraries/Core/Models/Enums/ItemTpl.cs index 4d3b8426..710b4866 100644 --- a/Libraries/Core/Models/Enums/ItemTpl.cs +++ b/Libraries/Core/Models/Enums/ItemTpl.cs @@ -1,6 +1,6 @@ namespace Core.Models.Enums; -public record ItemTpl +public static class ItemTpl { public static string AMMOBOX_127X33_COPPER_20RND = "676009ddb623f3b8ba079419"; public static string AMMOBOX_127X33_FMJ_20RND = "676009ed8f1fee08740f9479"; diff --git a/Libraries/Core/Models/Enums/Weapons.cs b/Libraries/Core/Models/Enums/Weapons.cs new file mode 100644 index 00000000..f99c8d17 --- /dev/null +++ b/Libraries/Core/Models/Enums/Weapons.cs @@ -0,0 +1,6 @@ +namespace Core.Models.Enums; + +public static class Weapons +{ + +} diff --git a/SptCommon/Extensions/StringExtensions.cs b/SptCommon/Extensions/StringExtensions.cs new file mode 100644 index 00000000..d8edecf0 --- /dev/null +++ b/SptCommon/Extensions/StringExtensions.cs @@ -0,0 +1,42 @@ +using System.Text.RegularExpressions; + +namespace SptCommon.Extensions; + +public static class StringExtensions +{ + private static readonly Dictionary RegexCache = new(); + private static readonly object RegexCacheLock = new(); + + public static string RegexReplace(this string source, string regexString, string newValue) + { + Regex regex; + lock (RegexCacheLock) + { + if (!RegexCache.TryGetValue(regexString, out regex)) + { + regex = new Regex(regexString); + RegexCache[regexString] = regex; + } + } + return regex.Replace(source, newValue); + } + + public static bool RegexMatch(this string source, string regexString, out Match? matchedString) + { + Regex regex; + lock (RegexCacheLock) + { + if (!RegexCache.TryGetValue(regexString, out regex)) + { + regex = new Regex(regexString); + RegexCache[regexString] = regex; + } + } + + matchedString = null; + if (!regex.IsMatch(source)) + return false; + matchedString = regex.Match(source); + return true; + } +} diff --git a/Tools/ItemTplGenerator/Class1.cs b/Tools/ItemTplGenerator/Class1.cs deleted file mode 100644 index da37140a..00000000 --- a/Tools/ItemTplGenerator/Class1.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace ItemTplGenerator; - -public class Class1 -{ -} diff --git a/Tools/ItemTplGenerator/ItemTplGenerator.cs b/Tools/ItemTplGenerator/ItemTplGenerator.cs new file mode 100644 index 00000000..a44d4c27 --- /dev/null +++ b/Tools/ItemTplGenerator/ItemTplGenerator.cs @@ -0,0 +1,577 @@ +using Core.DI; +using Core.Helpers; +using Core.Models.Eft.Common.Tables; +using Core.Models.Enums; +using Core.Servers; +using Core.Services; +using Microsoft.Extensions.Logging; +using SptCommon.Annotations; +using SptCommon.Extensions; +using Path = System.IO.Path; + +namespace ItemTplGenerator; + +[Injectable] +public class ItemTplGenerator( + DatabaseServer _databaseServer, + LocaleService _localeService, + ILogger _logger, + ItemHelper _itemHelper, + // @inject("FileSystemSync") protected fileSystemSync: FileSystemSync, + IEnumerable _onLoadComponents +) +{ + private string enumDir; + private Dictionary items; + private Dictionary itemOverrides; + private List collidedEnumKeys = []; + + public async Task Run() + { + // Load all of the onload components, this gives us access to most of SPTs injections + foreach (var onLoad in _onLoadComponents) + { + await onLoad.OnLoad(); + } + + // Figure out our source and target directories + var projectDir = Directory.GetParent("./").Parent; + enumDir = Path.Combine(projectDir.FullName, "Core", "Models", "Enums"); + items = _databaseServer.GetTables().Templates.Items; + itemOverrides = new Dictionary(); + + // Generate an object containing all item name to ID associations + var orderedItemsObject = GenerateItemsObject(); + + // Log any changes to enum values, so the source can be updated as required + LogEnumValueChanges(orderedItemsObject, "ItemTpl", typeof(ItemTpl)); + var itemTplOutPath = Path.Combine(enumDir, "ItemTpl.cs"); + WriteEnumsToFile( + itemTplOutPath, + new Dictionary>() { { nameof(ItemTpl), orderedItemsObject } } + ); + + // Handle the weapon type enums + var weaponsObject = GenerateWeaponsObject(); + LogEnumValueChanges(weaponsObject, "Weapons", typeof(Weapons)); + var weaponTypeOutPath = Path.Combine(enumDir, "Weapons.cs"); + WriteEnumsToFile( + weaponTypeOutPath, + new Dictionary>() { { nameof(Weapons), weaponsObject } } + ); + + _logger.LogInformation("Generating items finished"); + } + + /** + * Return an object containing all items in the game with a generated name + * @returns An object containing a generated item name to item ID association + */ + private Dictionary GenerateItemsObject() + { + var itemsObject = new Dictionary(); + foreach (var item in items.Values) + { + // Skip invalid items (Non-Item types, and shrapnel) + if (!IsValidItem(item)) continue; + + var itemParentName = GetParentName(item); + var itemPrefix = GetItemPrefix(item); + var itemName = GetItemName(item); + var itemSuffix = GetItemSuffix(item); + + // Handle the case where the item starts with the parent category name. Avoids things like 'POCKETS_POCKETS' + if (itemParentName == itemName.Substring(1, itemParentName.Length + 1) && itemPrefix == "") + { + itemName = itemName.Substring(itemParentName.Length + 1); + if (itemName.Length > 0 && itemName[0] != '_') + { + itemName = $"_{itemName}"; + } + } + + // Handle the case where the item ends with the parent category name. Avoids things like 'KEY_DORM_ROOM_103_KEY' + if (itemParentName == itemName.Substring(itemName.Length - itemParentName.Length)) + { + itemName = itemName.Substring(0, itemName.Length - itemParentName.Length); + + if (itemName.Substring(itemName.Length - 1) == "_") + { + itemName = itemName.Substring(0, itemName.Length - 1); + } + } + + var itemKey = $"{itemParentName}{itemPrefix}{itemName}{itemSuffix}"; + + // Strip out any remaining special characters + itemKey = SanitizeEnumKey(itemKey); + + // If the key already exists, see if we can add a suffix to both this, and the existing conflicting item + if (itemsObject.ContainsKey(itemKey) || collidedEnumKeys.Contains(itemKey)) + { + // Keep track, so we can handle 3+ conflicts + collidedEnumKeys.Add(itemKey); + + var itemNameSuffix = GetItemNameSuffix(item); + if (itemNameSuffix != null) + { + // Try to update the old key reference if we haven't already + if (itemsObject.ContainsKey(itemKey)) + { + var oldItemId = itemsObject[itemKey]; + var oldItemNameSuffix = GetItemNameSuffix(items[oldItemId]); + if (oldItemNameSuffix != null) + { + var oldItemNewKey = SanitizeEnumKey($"{itemKey}_{oldItemNameSuffix}"); + itemsObject.Remove(itemKey); + itemsObject[oldItemNewKey] = oldItemId; + } + } + + itemKey = SanitizeEnumKey($"{itemKey}_{itemNameSuffix}"); + + // If we still collide, log an error + if (itemsObject.ContainsKey(itemKey)) + { + _logger.LogError( + $"After rename, itemsObject already contains {itemKey} {itemsObject[itemKey]} => {item.Id}" + ); + } + } + else + { + _logger.LogError( + $"New itemOverride entry required: itemsObject already contains {itemKey} {itemsObject[itemKey]} => {item.Id}" + ); + continue; + } + } + + itemsObject[itemKey] = item.Id; + } + + // Sort the items object + var itemList = itemsObject.ToList(); + itemList.Sort((kv1, kv2) => kv1.Key.CompareTo(kv2.Key)); + var orderedItemsObject = itemList.ToDictionary(kv => kv.Key, kv => kv.Value); + /* I think the above should be the same? + var orderedItemsObject = Object.keys(itemsObject) + .sort() + .reduce((obj, key) => { + obj[key] = itemsObject[key]; + return obj; + }, {}); + */ + return orderedItemsObject; + } + + /** + * + * @param orderedItemsObject The previously generated object of item name to item ID associations + * @returns + */ + private Dictionary GenerateWeaponsObject() + { + var weaponsObject = new Dictionary(); + foreach (var kv /*[itemId, item]*/ in items) + { + if (!_itemHelper.IsOfBaseclass(kv.Key, BaseClasses.WEAPON)) + { + continue; + } + + var caliber = CleanCaliber(kv.Value.Properties.AmmoCaliber.ToUpper()); + var localeDb = _localeService.GetLocaleDb(); + var weaponShortName = _localeService.GetLocaleDb()[$"{kv.Key} ShortName"]?.ToUpper(); + + // Special case for the weird duplicated grenade launcher + if (kv.Key == "639c3fbbd0446708ee622ee9") + { + weaponShortName = "FN40GL_2"; + } + + // Include any bracketed suffixes that exist, handles the case of colored gun variants + var weaponFullName = _localeService.GetLocaleDb()[$"{kv.Key} Name"]?.ToUpper(); + if (weaponFullName.RegexMatch("\\((.+?)\\)$", out var itemNameBracketSuffix) && + !weaponShortName.EndsWith(itemNameBracketSuffix.Captures[1].Value)) + { + weaponShortName += $"_{itemNameBracketSuffix.Captures[1].Value}"; + } + + var parentName = GetParentName(kv.Value); + + // Handle special characters + var weaponName = $"{parentName}_{caliber}_{weaponShortName}" + .RegexReplace("[- ]", "_") + .RegexReplace("[^a-zA-Z0-9_]", "") + .ToUpper(); + + if (weaponsObject.ContainsKey(weaponName)) + { + _logger.LogError($"weapon {weaponName} already exists"); + continue; + } + + weaponsObject[weaponName] = kv.Key; + } + + // Sort the weapons object + var itemList = weaponsObject.ToList(); + itemList.Sort((kv1, kv2) => kv1.Key.CompareTo(kv2.Key)); + var orderedWeaponsObject = itemList.ToDictionary(kv => kv.Key, kv => kv.Value); + /* I think the above should be the same? + var orderedWeaponsObject = Object.keys(weaponsObject.Keys) + .sort() + .reduce((obj, key) => { + obj[key] = weaponsObject[key]; + return obj; + }, {}); + */ + return orderedWeaponsObject; + } + + /** + * Clear any non-alpha numeric characters, and fix multiple underscores + * @param enumKey The enum key to sanitize + * @returns The sanitized enum key + */ + private string SanitizeEnumKey(string enumKey) + { + return enumKey + .ToUpper() + .RegexReplace("[^A-Z0-9_]", "") + .RegexReplace("_+", "_"); + } + + private string GetParentName(TemplateItem item) + { + if (item.Properties?.QuestItem is true) + { + return "QUEST"; + } + + if (_itemHelper.IsOfBaseclass(item.Id, BaseClasses.BARTER_ITEM)) + { + return "BARTER"; + } + + if (_itemHelper.IsOfBaseclass(item.Id, BaseClasses.THROW_WEAPON)) + { + return "GRENADE"; + } + + if (_itemHelper.IsOfBaseclass(item.Id, BaseClasses.STIMULATOR)) + { + return "STIM"; + } + + if (_itemHelper.IsOfBaseclass(item.Id, BaseClasses.MAGAZINE)) + { + return "MAGAZINE"; + } + + if (_itemHelper.IsOfBaseclass(item.Id, BaseClasses.KEY_MECHANICAL)) + { + return "KEY"; + } + + if (_itemHelper.IsOfBaseclass(item.Id, BaseClasses.MOB_CONTAINER)) + { + return "SECURE"; + } + + if (_itemHelper.IsOfBaseclass(item.Id, BaseClasses.SIMPLE_CONTAINER)) + { + return "CONTAINER"; + } + + if (_itemHelper.IsOfBaseclass(item.Id, BaseClasses.PORTABLE_RANGE_FINDER)) + { + return "RANGEFINDER"; + } + + // Why are flares grenade launcher...? + if (item.Name.StartsWith("weapon_rsp30")) + { + return "FLARE"; + } + + // This is a special case for the signal pistol, I'm not adding it as a Grenade Launcher + if (item.Id == "620109578d82e67e7911abf2") + { + return "SIGNALPISTOL"; + } + + var parentId = item.Parent; + return items[parentId].Name.ToUpper(); + } + + private bool IsValidItem(TemplateItem item) + { + var shrapnelId = "5943d9c186f7745a13413ac9"; + + if (item.Type != "Item") + { + return false; + } + + if (item.Prototype == shrapnelId) + { + return false; + } + + return true; + } + + /** + * Generate a prefix for the passed in item + * @param item The item to generate the prefix for + * @returns The prefix of the given item + */ + private string GetItemPrefix(TemplateItem item) + { + var prefix = ""; + + // Prefix ammo with its caliber + if (_itemHelper.IsOfBaseclass(item.Id, BaseClasses.AMMO)) + { + prefix = GetAmmoPrefix(item); + } + // Prefix ammo boxes with their caliber + else if (_itemHelper.IsOfBaseclass(item.Id, BaseClasses.AMMO_BOX)) + { + prefix = GetAmmoBoxPrefix(item); + } + // Prefix magazines with their caliber + else if (_itemHelper.IsOfBaseclass(item.Id, BaseClasses.MAGAZINE)) + { + prefix = GetMagazinePrefix(item); + } + + // Make sure there's an underscore separator + if (prefix.Length > 0 && prefix[0] != '_') + { + prefix = $"_{prefix}"; + } + + return prefix; + } + + private string GetItemSuffix(TemplateItem item) + { + var suffix = ""; + + // Add mag size for magazines + if (_itemHelper.IsOfBaseclass(item.Id, BaseClasses.MAGAZINE)) + { + suffix = $"{item.Properties?.Cartridges?[0].MaxCount?.ToString()}RND"; + } + // Add pack size for ammo boxes + else if (_itemHelper.IsOfBaseclass(item.Id, BaseClasses.AMMO_BOX)) + { + suffix = $"{item.Properties.StackSlots[0]?.MaxCount.ToString()}RND"; + } + + // Add "DAMAGED" for damaged items + if (item.Name.ToLower().Contains("damaged")) + { + suffix += "_DAMAGED"; + } + + // Make sure there's an underscore separator + if (suffix.Length > 0 && suffix[0] != '_') + { + suffix = $"_{suffix}"; + } + + return suffix; + } + + private string GetAmmoPrefix(TemplateItem item) + { + var prefix = item.Properties.Caliber.ToUpper(); + + return CleanCaliber(prefix); + } + + private string CleanCaliber(string ammoCaliber) + { + var ammoCaliberToClean = ammoCaliber; + + ammoCaliberToClean = ammoCaliberToClean.Replace("CALIBER", ""); + ammoCaliberToClean = ammoCaliberToClean.Replace("PARA", ""); + ammoCaliberToClean = ammoCaliberToClean.Replace("NATO", ""); + + // Special case for 45ACP + ammoCaliberToClean = ammoCaliberToClean.Replace("1143X23ACP", "45ACP"); + + return ammoCaliberToClean; + } + + private string GetAmmoBoxPrefix(TemplateItem item) + { + var ammoItem = item.Properties?.StackSlots?[0]?.Props?.Filters?[0]?.Filter?[0]; + + return GetAmmoPrefix(items[ammoItem]); + } + + private string GetMagazinePrefix(TemplateItem item) + { + var ammoItem = item.Properties?.Cartridges?[0]?.Props?.Filters?[0]?.Filter?[0]; + + return GetAmmoPrefix(items[ammoItem]); + } + + /** + * Return the name of the passed in item, formatted for use in an enum + * @param item The item to generate the name for + * @returns The name of the given item + */ + private string GetItemName(TemplateItem item) + { + string? itemName = null; + var localeDb = _localeService.GetLocaleDb(); + + // Manual item name overrides + if (itemOverrides.ContainsKey(item.Id)) + { + itemName = itemOverrides[item.Id].ToUpper(); + } + // For the listed types, user the item's _name property + else if ( + _itemHelper.IsOfBaseclasses( + item.Id, + [ + BaseClasses.RANDOM_LOOT_CONTAINER, + BaseClasses.BUILT_IN_INSERTS, + BaseClasses.STASH, + ] + ) + ) + { + itemName = item.Name.ToUpper(); + } + // For the listed types, use the short name + else if (_itemHelper.IsOfBaseclasses(item.Id, [BaseClasses.AMMO, BaseClasses.AMMO_BOX, BaseClasses.MAGAZINE])) + { + if (localeDb.TryGetValue($"{item.Id} ShortName", out itemName)) + itemName.ToUpper(); + } + // For everything else, use the full name + else + { + if (localeDb.TryGetValue($"{item.Id} Name", out itemName)) + itemName.ToUpper(); + } + + // Fall back in the event we couldn't find a name + if (itemName == null) + { + if (localeDb.TryGetValue("{item.Id} Name", out itemName)) + itemName.ToUpper(); + } + + if (itemName == null) + { + itemName = item.Name?.ToUpper() ?? null; + } + + if (itemName == null) + { + Console.WriteLine($"Unable to get shortname for {item.Id}"); + return ""; + } + + itemName = itemName.Trim().RegexReplace("[-.()]", ""); + itemName = itemName.RegexReplace("[ ]", "_"); + + return $"_{itemName}"; + } + + private string? GetItemNameSuffix(TemplateItem item) + { + var localeDb = _localeService.GetLocaleDb(); + localeDb.TryGetValue($"{item.Id} Name", out var itemName); + + // Add grid size for lootable containers + if (_itemHelper.IsOfBaseclass(item.Id, BaseClasses.LOOT_CONTAINER)) + { + return $"{item.Properties.Grids[0]?.Props.CellsH}X${item.Properties.Grids[0]?.Props.CellsV}"; + } + + // Add ammo caliber to conflicting weapons + if (_itemHelper.IsOfBaseclass(item.Id, BaseClasses.WEAPON)) + { + var caliber = CleanCaliber(item.Properties.AmmoCaliber.ToUpper()); + + // If the item has a bracketed section at the end of its name, include that + if (itemName?.RegexMatch("\\((.+?)\\)$", out var itemNameBracketSuffix) ?? false) + { + return $"{caliber}_{itemNameBracketSuffix.Captures[1].Value}"; + } + + return caliber; + } + + // Make sure we have a full name + if (itemName == null) + { + return ""; + } + + // If the item has a bracketed section at the end of its name, use that + if (itemName.RegexMatch("\\((.+?)\\)$", out var itemNameBracker)) + { + return itemNameBracker.Captures[1].Value; + } + + // If the item has a number at the end of its name, use that + if (itemName.RegexMatch("#([0-9]+)$", out var itemNameNumberSuffix)) + { + return itemNameNumberSuffix.Captures[1].Value; + } + + return ""; + } + + private void LogEnumValueChanges(Dictionary data, string enumName, Type originalEnum) + { + // First generate a mapping of the original enum values to names + var originalEnumValues = new Dictionary(); + foreach (var field in originalEnum.GetFields()) + { + originalEnumValues.Add(field.GetValue(null)!.ToString()!, field.Name); + } + + // Loop through our new data, and find any where the given ID's name doesn't match the original enum + foreach (var kv in data) + { + if (originalEnumValues.ContainsKey(kv.Value) && originalEnumValues[kv.Value] != kv.Key) + { + _logger.LogWarning( + $"Enum {enumName} key has changed for {kv.Value}, {originalEnumValues[kv.Value]} => {kv.Key}" + ); + } + } + } + + private void WriteEnumsToFile(string outputPath, Dictionary> enumEntries) + { + var enumFileData = + "// This is an auto generated file, do not modify. Re-generate by running ItemTplGenerator.exe"; + + foreach (var (enumName, data) in enumEntries) + { + enumFileData += $"\npublic static class {enumName}\n{{\n"; + + foreach (var (key, value) in data) + { + enumFileData += $" {key} = \"{value}\";\n"; + } + + enumFileData += "}\n"; + } + + // this.fileSystemSync.write(outputPath, enumFileData); + } +} diff --git a/Tools/ItemTplGenerator/ItemTplGenerator.csproj b/Tools/ItemTplGenerator/ItemTplGenerator.csproj index 17b910f6..ebae4669 100644 --- a/Tools/ItemTplGenerator/ItemTplGenerator.csproj +++ b/Tools/ItemTplGenerator/ItemTplGenerator.csproj @@ -4,6 +4,13 @@ net9.0 enable enable + Exe + + + + + + diff --git a/Tools/ItemTplGenerator/ItemTplGeneratorLauncher.cs b/Tools/ItemTplGenerator/ItemTplGeneratorLauncher.cs new file mode 100644 index 00000000..649463b6 --- /dev/null +++ b/Tools/ItemTplGenerator/ItemTplGeneratorLauncher.cs @@ -0,0 +1,16 @@ +using Core.Utils; +using Microsoft.Extensions.DependencyInjection; +using SptDependencyInjection; + +namespace ItemTplGenerator; + +public class ItemTplGeneratorLauncher +{ + public static void Main(string[] args) + { + var serviceCollection = new ServiceCollection(); + DependencyInjectionRegistrator.RegisterSptComponents(typeof(ItemTplGeneratorLauncher).Assembly, typeof(App).Assembly, serviceCollection); + var serviceProvider = serviceCollection.BuildServiceProvider(); + serviceProvider.GetService().Run(); + } +}