diff --git a/ContentPatcher/Framework/Migrations/Migration_2_0.ForObjectContextTags.cs b/ContentPatcher/Framework/Migrations/Migration_2_0.ForObjectContextTags.cs new file mode 100644 index 000000000..dd6764ae2 --- /dev/null +++ b/ContentPatcher/Framework/Migrations/Migration_2_0.ForObjectContextTags.cs @@ -0,0 +1,216 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using ContentPatcher.Framework.Migrations.Internal; +using ContentPatcher.Framework.Patches; +using StardewModdingAPI; +using StardewModdingAPI.Framework.Content; +using StardewValley.Extensions; +using StardewValley.GameData.Objects; + +namespace ContentPatcher.Framework.Migrations +{ + internal partial class Migration_2_0 : BaseRuntimeMigration + { + // + // Known limitation: since we're combining two different assets, it's possible some mods added the context tags + // in Data/ObjectContextTags before adding the objects in Data/ObjectInformation. Unfortunately we can't add + // context tags to an object which doesn't exist yet, so those context tags will be ignored. + // + + /// The migration logic to apply pre-1.6 Data/ObjectContextTags patches to Data/Objects. + private class ObjectContextTagsMigrator : IEditAssetMigrator + { + /********* + ** Fields + *********/ + /// The pre-1.6 asset name. + private const string OldAssetName = "Data/ObjectContextTags"; + + /// The 1.6 asset name. + private const string NewAssetName = "Data/Objects"; + + + /********* + ** Public methods + *********/ + /// + public bool AppliesTo(IAssetName assetName) + { + return assetName?.IsEquivalentTo(ObjectContextTagsMigrator.OldAssetName, useBaseName: true) is true; + } + + /// + public IAssetName? RedirectTarget(IAssetName assetName, IPatch patch) + { + return new AssetName(ObjectContextTagsMigrator.NewAssetName, null, null); + } + + /// + public bool TryApplyLoadPatch(LoadPatch patch, IAssetName assetName, [NotNullWhen(true)] ref T? asset, out string? error) + { + // we can't migrate Action: Load patches because the patch won't actually contain any object data + // besides the context tags. + error = $"can't migrate load patches for '{ObjectContextTagsMigrator.OldAssetName}' to Stardew Valley 1.6"; + return false; + } + + /// + public bool TryApplyEditPatch(EditDataPatch patch, IAssetData asset, Action onWarning, out string? error) + { + var data = asset.GetData>(); + Dictionary tempData = this.GetOldFormat(data); + Dictionary tempDataBackup = new(tempData); + patch.Edit>(new FakeAssetData(asset, this.GetOldAssetName(asset.Name), tempData), onWarning); + this.MergeIntoNewFormat(data, tempData, tempDataBackup, patch.ContentPack.Manifest.UniqueID); + + error = null; + return true; + } + + + /********* + ** Private methods + *********/ + /// Get the old asset to edit. + /// The new asset name whose locale to use. + private IAssetName GetOldAssetName(IAssetName newName) + { + return new AssetName(ObjectContextTagsMigrator.OldAssetName, newName.LocaleCode, newName.LanguageCode); + } + + /// Get the pre-1.6 equivalent for the new asset data. + /// The data to convert. + private Dictionary GetOldFormat(IDictionary asset) + { + var data = new Dictionary(); + + foreach ((string objectId, ObjectData entry) in asset) + { + if (entry.Name is null) + continue; + + string key = this.GetOldEntryKey(objectId, entry); + data[key] = entry.ContextTags?.Count > 0 + ? string.Join(", ", entry.ContextTags) + : string.Empty; + } + + return data; + } + + /// Merge pre-1.6 data into the new asset. + /// The asset data to update. + /// The pre-1.6 data to merge into the asset. + /// A copy of before edits were applied. + /// The unique ID for the mod, used in auto-generated entry IDs. + private void MergeIntoNewFormat(IDictionary asset, IDictionary contextTags, IDictionary? contextTagsBackup, string modId) + { + // skip if no entries changed + // (We can't remove unchanged entries though, since we need to combine context tags by both ID and name) + if (contextTagsBackup is not null) + { + bool anyChanged = false; + + foreach ((string oldKey, string rawTags) in contextTags) + { + if (!contextTagsBackup.TryGetValue(oldKey, out string? prevRawTags) || prevRawTags != rawTags) + { + anyChanged = true; + break; + } + } + + if (!anyChanged) + return; + } + + // get context tags by item ID + var contextTagsById = new Dictionary>(); + { + ILookup itemIdsByName = asset.ToLookup(p => p.Value.Name, p => p.Key); + + foreach ((string oldKey, string rawTags) in contextTags) + { + string[] tags = rawTags.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.RemoveEmptyEntries); + + // add by ID + if (oldKey.StartsWith("id_")) + { + if (oldKey.StartsWith("id_o_")) + { + string objectId = oldKey.Substring("id_o_".Length); + this.TrackRawContextTagsById(contextTagsById, objectId, tags); + } + } + + // else by name + else + { + foreach (string objectId in itemIdsByName[oldKey]) + this.TrackRawContextTagsById(contextTagsById, objectId, tags); + } + } + } + + // merge into Data/Objects + foreach ((string oldKey, HashSet tags) in contextTagsById) + { + // get or add matching object record + if (!asset.TryGetValue(oldKey, out ObjectData? entry)) + continue; + + // update context tags + if (tags.Count == 0) + entry.ContextTags?.Clear(); + else + { + entry.ContextTags ??= new List(); + entry.ContextTags.Clear(); + entry.ContextTags.AddRange(tags); + } + } + } + + /// Add context tags to a lookup by object ID. + /// The lookup to update. + /// The object ID whose context tags to track. + /// The context tags to track, in addition to any already tracked for the same object ID. + private void TrackRawContextTagsById(Dictionary> contextTagsById, string objectId, string[] tags) + { + // merge into previous + if (contextTagsById.TryGetValue(objectId, out HashSet? prevTags)) + prevTags.AddRange(tags); + + // else add new + else + contextTagsById[objectId] = new HashSet(tags); + } + + /// Get the entry key in Data/ObjectContextTags for an entry. + /// The unique object ID. + /// The object data. + private string GetOldEntryKey(string objectId, ObjectData entry) + { + switch (objectId) + { + case "113": // Chicken Statue + case "126": // Strange Doll #1 + case "127": // Strange Doll #2 + case "340": // Honey + case "342": // Pickles + case "344": // Jelly + case "348": // Wine + case "350": // Juice + case "447": // Aged Roe + case "812": // Roe + return "id_0_" + objectId; // match pre-1.6 key + + default: + return entry.Name ?? "id_0_" + objectId; + } + } + } + } +} diff --git a/ContentPatcher/Framework/Migrations/Migration_2_0.ForWeapons.cs b/ContentPatcher/Framework/Migrations/Migration_2_0.ForWeapons.cs new file mode 100644 index 000000000..d7c01acfe --- /dev/null +++ b/ContentPatcher/Framework/Migrations/Migration_2_0.ForWeapons.cs @@ -0,0 +1,161 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using ContentPatcher.Framework.Migrations.Internal; +using ContentPatcher.Framework.Patches; +using StardewModdingAPI; +using StardewValley; +using StardewValley.GameData.Weapons; +using StardewTokenParser = StardewValley.TokenizableStrings.TokenParser; + +namespace ContentPatcher.Framework.Migrations +{ + internal partial class Migration_2_0 : BaseRuntimeMigration + { + /// The migration logic to apply pre-1.6 Data/Weapons patches to the new format. + private class WeaponsMigrator : IEditAssetMigrator + { + /********* + ** Fields + *********/ + /// The asset name. + private const string AssetName = "Data/Weapons"; + + + /********* + ** Public methods + *********/ + /// + public bool AppliesTo(IAssetName assetName) + { + return assetName?.IsEquivalentTo(WeaponsMigrator.AssetName, useBaseName: true) is true; + } + + /// + public IAssetName? RedirectTarget(IAssetName assetName, IPatch patch) + { + return null; // same asset name + } + + /// + public bool TryApplyLoadPatch(LoadPatch patch, IAssetName assetName, [NotNullWhen(true)] ref T? asset, out string? error) + { + Dictionary tempData = patch.Load>(assetName); + Dictionary newData = new(); + this.MergeIntoNewFormat(newData, tempData, null); + asset = (T)(object)newData; + + error = null; + return true; + } + + /// + public bool TryApplyEditPatch(EditDataPatch patch, IAssetData asset, Action onWarning, out string? error) + { + var data = asset.GetData>(); + Dictionary tempData = this.GetOldFormat(data); + Dictionary tempDataBackup = new(tempData); + patch.Edit>(new FakeAssetData(asset, asset.Name, tempData), onWarning); + this.MergeIntoNewFormat(data, tempData, tempDataBackup); + + error = null; + return true; + } + + + /********* + ** Private methods + *********/ + /// Get the pre-1.6 equivalent for the new asset data. + /// The data to convert. + private Dictionary GetOldFormat(IDictionary from) + { + var data = new Dictionary(); + + string[] fields = new string[15]; + foreach ((string objectId, WeaponData entry) in from) + { + fields[0] = entry.Name; + fields[1] = StardewTokenParser.ParseText(entry.Description); + fields[2] = entry.MinDamage.ToString(); + fields[3] = entry.MaxDamage.ToString(); + fields[4] = entry.Knockback.ToString(); + fields[5] = entry.Speed.ToString(); + fields[6] = entry.Precision.ToString(); + fields[7] = entry.Defense.ToString(); + fields[8] = entry.Type.ToString(); + fields[9] = entry.MineBaseLevel.ToString(); + fields[10] = entry.MineMinLevel.ToString(); + fields[11] = entry.AreaOfEffect.ToString(); + fields[12] = entry.CritChance.ToString(); + fields[13] = entry.CritMultiplier.ToString(); + fields[14] = StardewTokenParser.ParseText(entry.DisplayName); + + data[objectId] = string.Join('/', fields); + } + + return data; + } + + /// Merge pre-1.6 data into the new asset. + /// The asset data to update. + /// The pre-1.6 data to merge into the asset. + /// A copy of before edits were applied. + private void MergeIntoNewFormat(IDictionary asset, IDictionary from, IDictionary? fromBackup) + { + // remove deleted entries + foreach (string key in asset.Keys) + { + if (!from.ContainsKey(key)) + asset.Remove(key); + } + + // apply entries + foreach ((string objectId, string fromEntry) in from) + { + // skip if unchanged + string[]? backupFields = null; + if (fromBackup is not null) + { + if (fromBackup.TryGetValue(objectId, out string? prevRow) && prevRow == fromEntry) + continue; // no changes + backupFields = prevRow?.Split('/'); + } + + // get/add target record + bool isNew = false; + if (!asset.TryGetValue(objectId, out WeaponData? entry)) + { + isNew = true; + entry = new WeaponData(); + } + + // merge fields into new asset + { + string[] fields = fromEntry.Split('/'); + + entry.Name = ArgUtility.Get(fields, 0, entry.Name, allowBlank: false); + entry.Description = RuntimeMigrationHelper.GetValueForTokenizableFieldIfChanged(ArgUtility.Get(fields, 1), ArgUtility.Get(backupFields, 1), entry.Description); + entry.MinDamage = ArgUtility.GetInt(fields, 2, entry.MinDamage); + entry.MaxDamage = ArgUtility.GetInt(fields, 3, entry.MaxDamage); + entry.Knockback = ArgUtility.GetFloat(fields, 4, entry.Knockback); + entry.Speed = ArgUtility.GetInt(fields, 5, entry.Speed); + entry.Precision = ArgUtility.GetInt(fields, 6, entry.Precision); + entry.Defense = ArgUtility.GetInt(fields, 7, entry.Defense); + entry.Type = ArgUtility.GetInt(fields, 8, entry.Type); + entry.MineBaseLevel = ArgUtility.GetInt(fields, 9, entry.MineBaseLevel); + entry.MineMinLevel = ArgUtility.GetInt(fields, 10, entry.MineMinLevel); + entry.AreaOfEffect = ArgUtility.GetInt(fields, 11, entry.AreaOfEffect); + entry.CritChance = ArgUtility.GetFloat(fields, 12, entry.CritChance); + entry.CritMultiplier = ArgUtility.GetFloat(fields, 13, entry.CritMultiplier); + entry.DisplayName = RuntimeMigrationHelper.GetValueForTokenizableFieldIfChanged(ArgUtility.Get(fields, 14), ArgUtility.Get(backupFields, 14), entry.DisplayName); + } + + // set value + if (isNew) + asset[objectId] = entry; + } + } + } + } +} diff --git a/ContentPatcher/Framework/Migrations/Migration_2_0.cs b/ContentPatcher/Framework/Migrations/Migration_2_0.cs index 4545a5bc4..7d990db05 100644 --- a/ContentPatcher/Framework/Migrations/Migration_2_0.cs +++ b/ContentPatcher/Framework/Migrations/Migration_2_0.cs @@ -28,7 +28,9 @@ public Migration_2_0() new CropsMigrator(), new LocationsMigrator(), new NpcDispositionsMigrator(), - new ObjectInformationMigrator() + new ObjectContextTagsMigrator(), + new ObjectInformationMigrator(), + new WeaponsMigrator() ]; }