From 35f23a2960dba296e07252b453a8a7d78a048808 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Fri, 8 Mar 2024 23:28:15 -0500 Subject: [PATCH] add trigger action to migrate legacy content IDs --- .../Framework/TriggerActions/MigrateIdType.cs | 24 ++ .../TriggerActions/MigrateIdsAction.cs | 346 ++++++++++++++++++ ContentPatcher/ModEntry.cs | 6 + ContentPatcher/docs/author-guide.md | 7 + .../docs/author-guide/trigger-actions.md | 51 +++ ContentPatcher/docs/release-notes.md | 1 + 6 files changed, 435 insertions(+) create mode 100644 ContentPatcher/Framework/TriggerActions/MigrateIdType.cs create mode 100644 ContentPatcher/Framework/TriggerActions/MigrateIdsAction.cs create mode 100644 ContentPatcher/docs/author-guide/trigger-actions.md diff --git a/ContentPatcher/Framework/TriggerActions/MigrateIdType.cs b/ContentPatcher/Framework/TriggerActions/MigrateIdType.cs new file mode 100644 index 000000000..b44231c03 --- /dev/null +++ b/ContentPatcher/Framework/TriggerActions/MigrateIdType.cs @@ -0,0 +1,24 @@ +namespace ContentPatcher.Framework.TriggerActions +{ + /// A data ID type which can be migrated using . + public enum MigrateIdType + { + /// Migrate cooking recipe IDs. + CookingRecipes, + + /// Migrate crafting recipe IDs. + CraftingRecipes, + + /// Migrate event IDs. + Events, + + /// Migrate item local IDs. + Items, + + /// Migrate mail IDs. + Mail, + + /// Migrate songs-heard cue names. + Songs + } +} diff --git a/ContentPatcher/Framework/TriggerActions/MigrateIdsAction.cs b/ContentPatcher/Framework/TriggerActions/MigrateIdsAction.cs new file mode 100644 index 000000000..634342f4b --- /dev/null +++ b/ContentPatcher/Framework/TriggerActions/MigrateIdsAction.cs @@ -0,0 +1,346 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using StardewValley; +using StardewValley.Delegates; +using StardewValley.ItemTypeDefinitions; +using StardewValley.Triggers; + +namespace ContentPatcher.Framework.TriggerActions +{ + /// Implements the Pathoschild.ContentPatcher_MigrateIds trigger action. + internal class MigrateIdsAction + { + /********* + ** Public methods + *********/ + /// Handle the action when it's called by the game. + /// + public bool Handle(string[] args, TriggerActionContext context, [NotNullWhen(false)] out string? error) + { + // validate context + // We need to migrate IDs everywhere, including in non-synced locations and on farmhand fields that can't + // be edited remotely. That's only possible when run on the host before any other players have connected. + if (context.Data is null) + { + error = "this action must be run via Data/TriggerActions"; + return false; + } + if (!context.Data.HostOnly || !string.Equals(context.Data.Trigger?.Trim(), TriggerActionManager.trigger_dayStarted)) + { + error = $"this action must be run with `\"{nameof(context.Data.HostOnly)}\": true` and `\"{nameof(context.Data.Trigger)}: \"{TriggerActionManager.trigger_dayStarted}\"`"; + return false; + } + + // get ID type + if (!ArgUtility.TryGetEnum(args, 1, out MigrateIdType type, out error)) + return false; + + // get old => new IDs + var mapIds = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (int i = 2; i < args.Length; i += 2) + { + if (!ArgUtility.TryGet(args, i, out string oldId, out error, allowBlank: false)) + return false; + if (!ArgUtility.TryGet(args, i + 1, out string newId, out error, allowBlank: false)) + { + if (!ArgUtility.HasIndex(args, i + 1)) + error = $"index {i} with old ID \"{oldId}\" doesn't have a corresponding new ID at index {i + 1}"; + return false; + } + + mapIds[oldId] = newId; + } + + // apply + Farmer[] players = Game1.getAllFarmers().ToArray(); + switch (type) + { + case MigrateIdType.CookingRecipes: + return this.TryMigrateCookingRecipeIds(players, mapIds, out error); + + case MigrateIdType.CraftingRecipes: + return this.TryMigrateCraftingRecipeIds(players, mapIds, out error); + + case MigrateIdType.Events: + return this.TryMigrateEventIds(players, mapIds, out error); + + case MigrateIdType.Items: + return this.TryMigrateItemIds(mapIds, out error); + + case MigrateIdType.Mail: + return this.TryMigrateMailIds(players, mapIds, out error); + + case MigrateIdType.Songs: + return this.TryMigrateSongIds(players, mapIds, out error); + + default: + error = $"required index 1 has unknown ID type '{type}'"; + return false; + } + + + } + + + /********* + ** Private methods + *********/ + /// Try to migrate cooking recipe IDs. + /// The players to edit. + /// The old and new IDs to map. + /// An error indicating why the migration failed. + private bool TryMigrateCookingRecipeIds(IEnumerable players, IDictionary mapIds, [NotNullWhen(false)] out string? error) + { + foreach (Farmer player in players) + { + // note: we iterate deliberately so keys are matched case-insensitively + + foreach ((string oldKey, int oldValue) in player.cookingRecipes.Pairs.ToArray()) + { + if (mapIds.TryGetValue(oldKey, out string? newKey)) + { + player.cookingRecipes.Remove(oldKey); + player.cookingRecipes.TryAdd(newKey, oldValue); + } + } + + foreach ((string oldKey, int oldValue) in player.craftingRecipes.Pairs.ToArray()) + { + if (mapIds.TryGetValue(oldKey, out string? newKey)) + { + player.craftingRecipes.Remove(oldKey); + player.craftingRecipes.TryAdd(newKey, oldValue); + } + } + } + + error = null; + return true; + } + + /// Try to migrate crafting recipe IDs. + /// The players to edit. + /// The old and new IDs to map. + /// An error indicating why the migration failed. + private bool TryMigrateCraftingRecipeIds(IEnumerable players, IDictionary mapIds, [NotNullWhen(false)] out string? error) + { + foreach (Farmer player in players) + { + foreach ((string oldKey, int oldValue) in player.craftingRecipes.Pairs.ToArray()) + { + if (mapIds.TryGetValue(oldKey, out string? newKey)) + { + player.craftingRecipes.Remove(oldKey); + player.craftingRecipes.TryAdd(newKey, oldValue); + } + } + } + + error = null; + return true; + } + + /// Try to migrate event IDs. + /// The players to edit. + /// The old and new IDs to map. + /// An error indicating why the migration failed. + private bool TryMigrateEventIds(IEnumerable players, IDictionary mapIds, [NotNullWhen(false)] out string? error) + { + foreach (Farmer player in players) + { + foreach (string oldId in player.eventsSeen.ToArray()) + { + if (mapIds.TryGetValue(oldId, out string? newId)) + { + player.eventsSeen.Remove(oldId); + player.eventsSeen.Add(newId); + } + } + } + + error = null; + return true; + } + + /// Try to migrate item IDs. + /// The old and new IDs to map. + /// An error indicating why the migration failed. + private bool TryMigrateItemIds(IDictionary mapRawIds, [NotNullWhen(false)] out string? error) + { + // validate & index item IDs + var mapQualifiedIds = new Dictionary(); + var mapLocalIds = new Dictionary(); + foreach ((string oldId, string newId) in mapRawIds) + { + if (!ItemRegistry.IsQualifiedItemId(oldId)) + { + error = $"the old item ID \"{oldId}\" must be a qualified item ID (like {ItemRegistry.type_object}{oldId})"; + return false; + } + + ItemMetadata data = ItemRegistry.ResolveMetadata(newId); + if (data is null) + { + error = $"the new item ID \"{newId}\" doesn't match an existing item"; + return false; + } + + mapQualifiedIds[data.QualifiedItemId] = data; + mapLocalIds[data.LocalItemId] = data; + } + + // migrate items + Utility.ForEachItem(item => + { + if (mapQualifiedIds.TryGetValue(item.QualifiedItemId, out ItemMetadata? data)) + item.ItemId = data.LocalItemId; + + return true; + }); + + // migrate indirect references + foreach (Farmer player in Game1.getAllFarmers()) + { + // artifacts (unqualified IDs) + foreach ((string oldId, int[] oldValue) in player.archaeologyFound.Pairs.ToArray()) + { + if (mapLocalIds.TryGetValue(oldId, out ItemMetadata? data)) + { + player.archaeologyFound.Remove(oldId); + player.archaeologyFound.TryAdd(data.LocalItemId, oldValue); + } + } + + // fish caught (qualified IDs) + foreach ((string oldId, int[] oldValue) in player.fishCaught.Pairs.ToArray()) + { + if (mapQualifiedIds.TryGetValue(oldId, out ItemMetadata? data)) + { + player.fishCaught.Remove(oldId); + player.fishCaught.TryAdd(data.QualifiedItemId, oldValue); + } + } + + // gifted items (unqualified IDs) + foreach (SerializableDictionary giftedItems in player.giftedItems.Values) + { + foreach ((string oldId, int oldValue) in giftedItems.ToArray()) + { + if (mapLocalIds.TryGetValue(oldId, out ItemMetadata? data)) + { + giftedItems.Remove(oldId); + giftedItems.TryAdd(data.LocalItemId, oldValue); + } + } + } + + // minerals (unqualified IDs) + foreach ((string oldId, int oldValue) in player.mineralsFound.Pairs.ToArray()) + { + if (mapLocalIds.TryGetValue(oldId, out ItemMetadata? data)) + { + player.mineralsFound.Remove(oldId); + player.mineralsFound.TryAdd(data.LocalItemId, oldValue); + } + } + + // shipped (unqualified IDs) + foreach ((string oldId, int oldValue) in player.basicShipped.Pairs.ToArray()) + { + if (mapLocalIds.TryGetValue(oldId, out ItemMetadata? data)) + { + player.basicShipped.Remove(oldId); + player.basicShipped.TryAdd(data.LocalItemId, oldValue); + } + } + + // tailored (IDs in legacy 'standard description' format) + foreach ((string oldTailoredId, int oldValue) in player.tailoredItems.Pairs.ToArray()) + { +#pragma warning disable CS0618 // deliberately using obsolete methods used by tailoredItems + + Item oldItem = Utility.getItemFromStandardTextDescription(oldTailoredId, Game1.player); + + if (oldItem != null && mapQualifiedIds.TryGetValue(oldItem.QualifiedItemId, out ItemMetadata? data)) + { + string newTailoredId = Utility.getStandardDescriptionFromItem(data.TypeIdentifier, data.LocalItemId, false, false, 1); + + player.tailoredItems.Remove(oldTailoredId); + player.tailoredItems.TryAdd(newTailoredId, oldValue); + } +#pragma warning restore CS0618 + } + } + + error = null; + return true; + } + + /// Try to migrate mail IDs. + /// The players to edit. + /// The old and new IDs to map. + /// An error indicating why the migration failed. + private bool TryMigrateMailIds(IEnumerable players, IDictionary mapIds, [NotNullWhen(false)] out string? error) + { + foreach (Farmer player in players) + { + // received + foreach (string oldId in player.mailReceived.ToArray()) + { + if (mapIds.TryGetValue(oldId, out string? newId)) + { + player.mailReceived.Remove(oldId); + player.mailReceived.Add(newId); + } + } + + // in mailbox + for (int i = 0; i < player.mailbox.Count; i++) + { + if (mapIds.TryGetValue(player.mailbox[i], out string? newId)) + { + player.mailbox.RemoveAt(i); + player.mailbox.Insert(i, newId); + } + } + + // queued for tomorrow + foreach (string oldId in player.mailForTomorrow.ToArray()) + { + if (mapIds.TryGetValue(oldId, out string? newId)) + { + player.mailForTomorrow.Remove(oldId); + player.mailForTomorrow.Add(newId); + } + } + } + + error = null; + return true; + } + + /// Try to migrate song IDs. + /// The players to edit. + /// The old and new IDs to map. + /// An error indicating why the migration failed. + private bool TryMigrateSongIds(IEnumerable players, IDictionary mapIds, [NotNullWhen(false)] out string? error) + { + foreach (Farmer player in players) + { + foreach (string oldId in player.songsHeard.ToArray()) + { + if (mapIds.TryGetValue(oldId, out string? newId)) + { + player.songsHeard.Remove(oldId); + player.songsHeard.Add(newId); + } + } + } + + error = null; + return true; + } + } +} diff --git a/ContentPatcher/ModEntry.cs b/ContentPatcher/ModEntry.cs index 66895f9c3..9c793c5bc 100644 --- a/ContentPatcher/ModEntry.cs +++ b/ContentPatcher/ModEntry.cs @@ -11,6 +11,7 @@ using ContentPatcher.Framework.Migrations; using ContentPatcher.Framework.Patches; using ContentPatcher.Framework.Tokens; +using ContentPatcher.Framework.TriggerActions; using ContentPatcher.Framework.Validators; using Pathoschild.Stardew.Common; using Pathoschild.Stardew.Common.Utilities; @@ -18,6 +19,7 @@ using StardewModdingAPI.Events; using StardewModdingAPI.Utilities; using StardewValley; +using StardewValley.Triggers; using TokenParser = ContentPatcher.Framework.TokenParser; [assembly: InternalsVisibleTo("Pathoschild.Stardew.Tests.Mods")] @@ -287,6 +289,10 @@ group token by token.Mod into modGroup helper.Events.Player.Warped += this.OnWarped; helper.Events.Specialized.LoadStageChanged += this.OnLoadStageChanged; + // set up trigger actions + // (This needs to happen before content packs are loaded below, since they may use these.) + TriggerActionManager.RegisterAction($"{this.ModManifest.UniqueID}_MigrateIds", new MigrateIdsAction().Handle); + // load screen manager this.InitializeScreenManagerIfNeeded(this.ContentPacks); diff --git a/ContentPatcher/docs/author-guide.md b/ContentPatcher/docs/author-guide.md index babf88bb6..cfa92639b 100644 --- a/ContentPatcher/docs/author-guide.md +++ b/ContentPatcher/docs/author-guide.md @@ -20,6 +20,7 @@ This document helps mod authors create a content pack for Content Patcher. * [Player config](#player-config) * [Translations](#translations) * [Text operations](#text-operations) + * [Trigger actions](#trigger-actions) * [Troubleshoot](#troubleshoot) * [FAQs](#faqs) * [How often are patch changes applied?](#update-rate) @@ -440,6 +441,12 @@ For example, this adds pufferfish as a universally loved gift: See the [text operations documentation](author-guide/text-operations.md) for more info. +### Trigger actions +Content Patcher adds custom [trigger actions](https://stardewvalleywiki.com/Modding:Trigger_actions) for specialized +cases like updating pre-existing saves for renamed content IDs. + +See [Content Patcher's trigger action documentation](author-guide/trigger-actions.md) for more info. + ## Troubleshoot See the [troubleshooting guide](author-guide/troubleshooting.md) for more info. diff --git a/ContentPatcher/docs/author-guide/trigger-actions.md b/ContentPatcher/docs/author-guide/trigger-actions.md new file mode 100644 index 000000000..bd4465b6a --- /dev/null +++ b/ContentPatcher/docs/author-guide/trigger-actions.md @@ -0,0 +1,51 @@ +← [author guide](../author-guide.md) + +This page documents the custom [trigger actions](https://stardewvalleywiki.com/Modding:Trigger_actions) added by +Content Patcher. + +## Contents +* [`MigrateIds`](#migrateids) +* [See also](#see-also) + +## `MigrateIds` +The `Pathoschild.ContentPatcher_MigrateIds` [trigger action](https://stardewvalleywiki.com/Modding:Trigger_actions) +lets you update existing saves when you change IDs for your events, items, mail, recipes, or songs. For example, this +can be used to migrate to [unique string IDs](https://stardewvalleywiki.com/Modding:Common_data_field_types#Unique_string_ID). + +The argument format is ` [ ]+`, where: +- `` is one of `CookingRecipes`, `CraftingRecipes`, `Events`, `Items`, `Mail`, or `Songs`; +- `` is the current ID to find in the game data; +- `` is the new ID to change it to. + +You can have any number old/new ID pairs. + +For example, this changes the ID for two crafting recipes: `Puffer Plush` renamed to `{{ModId}}_PufferPlush`, and `Puffer +Sofa` renamed to `{{ModId}}_PufferSofa`: + +```js +{ + "Action": "EditData", + "Target": "Data/TriggerActions", + "Entries": { + "{{ModId}}_MigrateIds": { + "Id": "{{ModId}}_MigrateIds", + "Trigger": "DayStarted", + "Actions": [ + // Note: use double-quotes around an argument if it contains spaces. This example has single-quotes for + // the action itself, so we don't need to escape the double-quotes inside it. + 'Pathoschild.ContentPatcher_MigrateIds CraftingRecipes "Puffer Plush" {{ModId}}_PufferPlush "Puffer Sofa" {{ModId}}_PufferSofa' + ], + "HostOnly": true + } + } +} +``` + +> [!IMPORTANT] +> Content Patcher needs full access to the whole game state to do this. The action will log an error if: +>* it isn't set to `"Trigger": "DayStarted"` and `"HostOnly": true`. +>* or it's not being run from `Data/TriggerActions`. + +## See also +* [Author guide](../author-guide.md) for other actions and options +* [_Trigger actions_ on the wiki](https://stardewvalleywiki.com/Modding:Trigger_actions) for more info diff --git a/ContentPatcher/docs/release-notes.md b/ContentPatcher/docs/release-notes.md index 696256cb8..9abcc92b4 100644 --- a/ContentPatcher/docs/release-notes.md +++ b/ContentPatcher/docs/release-notes.md @@ -14,6 +14,7 @@ When releasing a format change, don't forget to update the smapi.io/json schema! * Added asset load & edit priority (see updated [action docs](author-guide.md#actions)). * Added [`ModId`](author-guide/tokens.md#ModId) token to get the unique ID of the current content pack. * Added runtime migrations for content assets which changed in Stardew Valley 1.6. (Thanks to SinZ for the help creating some of the main migrations!) +* Added trigger action to change content IDs automatically (see [new docs](author-guide/trigger-actions.md)). * Deprecated `CustomLocations`. This is now a shortcut for editing the new [`Data/Locations` asset](https://stardewvalleywiki.com/Modding:Location_data), and now allows the [new location name format](https://stardewvalleywiki.com/Modding:Modder_Guide/Game_Fundamentals#Unique_string_IDs). * Removed `GroupEditsByMod` config option. Edits are now grouped automatically based on mod and priority. * Fixed off-by-one position with `MoveEntries` when the target entry is already before the anchor entry.