diff --git a/build/common.targets b/build/common.targets index d538cc53..ce805402 100644 --- a/build/common.targets +++ b/build/common.targets @@ -1,7 +1,7 @@ - 3.10.1 + 3.11.0 SMAPI latest $(AssemblySearchPaths);{GAC} diff --git a/docs/release-notes.md b/docs/release-notes.md index caa90d19..d0aa2fbf 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -7,6 +7,30 @@ * Migrated to Harmony 2.0 (see [_migrate to Harmony 2.0_](https://stardewvalleywiki.com/Modding:Migrate_to_Harmony_2.0) for more info). --> +## 3.11.0 +Released 09 July 2021 for Stardew Valley 1.5.4 or later. See [release highlights](https://www.patreon.com/posts/53514295). + +* For players: + * Updated for Stardew Valley 1.4.5 multiplayer hotfix on Linux/macOS. + * Fixed installer error on Windows when running as administrator (thanks to LostLogic!). + * Fixed installer error on some Windows systems (thanks to eddyballs!). + * Fixed error if SMAPI fails to dispose on game exit. + * Fixed `player_add` and `list_items` console commands not including some shirts _(in Console Commands)_. + +* For mod authors: + * Added `World.FurnitureListChanged` event (thanks to DiscipleOfEris!). + * Added asset propagation for building/house paint masks. + * Added log message for troubleshooting if Windows software which often causes issues is installed (currently MSI Afterburner and RivaTuner). + * Improved validation for the manifest `Dependencies` field. + * Fixed validation for mods with invalid version `0.0.0`. + * Fixed _loaded with custom settings_ trace log added when using default settings. + * Fixed `Constants.SaveFolderName` and `Constants.CurrentSavePath` not set correctly in rare cases. + +* For the web UI and JSON validator: + * Updated the JSON validator/schema for Content Patcher 1.23.0. + * Fixed [JSON schema](technical/web.md#using-a-schema-file-directly) in Visual Studio Code warning about comments and trailing commas. + * Fixed JSON schema for `i18n` files requiring the wrong value for the `$schema` field. + ## 3.10.1 Released 03 May 2021 for Stardew Valley 1.5.4 or later. diff --git a/docs/technical/mod-package.md b/docs/technical/mod-package.md index 6d04722c..91da971b 100644 --- a/docs/technical/mod-package.md +++ b/docs/technical/mod-package.md @@ -324,7 +324,7 @@ To do that: PATH_HERE ``` -3. Replace `PATH_HERE` with your game's folder path. +3. Replace `PATH_HERE` with your game's folder path (don't add quotes). The configuration will check your custom path first, then fall back to the default paths (so it'll still compile on a different computer). diff --git a/src/SMAPI.Installer/assets/unix-launcher.sh b/src/SMAPI.Installer/assets/unix-launcher.sh index a33c0d7f..d309f750 100644 --- a/src/SMAPI.Installer/assets/unix-launcher.sh +++ b/src/SMAPI.Installer/assets/unix-launcher.sh @@ -43,8 +43,34 @@ if [ "$UNAME" == "Darwin" ]; then cp -p StardewValley.bin.osx StardewModdingAPI.bin.osx fi + # Make sure we're running in Terminal (so the user can see errors/warnings/update alerts). + # Previously we would just use `open -a Terminal` to launch the .bin.osx file, but that + # doesn't let us set environment variables. + if [ ! -t 1 ]; then # https://stackoverflow.com/q/911168/262123 + # sanity check to make sure we don't have an infinite loop of opening windows + SKIP_TERMINAL=false + for argument in "$@"; do + if [ "$argument" == "--no-reopen-terminal" ]; then + SKIP_TERMINAL=true + break + fi + done + + # reopen in Terminal if needed + # https://stackoverflow.com/a/29511052/262123 + if [ "$SKIP_TERMINAL" == "false" ]; then + echo "Reopening in the Terminal app..." + echo "\"$0\" $@ --no-reopen-terminal" > /tmp/open-smapi-terminal.sh + chmod +x /tmp/open-smapi-terminal.sh + cat /tmp/open-smapi-terminal.sh + open -W -a Terminal /tmp/open-smapi-terminal.sh + rm /tmp/open-smapi-terminal.sh + exit 0 + fi + fi + # launch SMAPI - open -a Terminal ./StardewModdingAPI.bin.osx "$@" + LC_ALL="C" ./StardewModdingAPI.bin.osx "$@" else # choose binary file to launch LAUNCH_FILE="" @@ -79,44 +105,44 @@ else terminal|termite) # consumes only one argument after -e # options containing space characters are unsupported - exec $TERMINAL_NAME -e "env TERM=xterm $LAUNCH_FILE $@" + exec $TERMINAL_NAME -e "env TERM=xterm LC_ALL=\"C\" $LAUNCH_FILE $@" ;; xterm|konsole|alacritty) # consumes all arguments after -e - exec $TERMINAL_NAME -e env TERM=xterm $LAUNCH_FILE "$@" + exec $TERMINAL_NAME -e env TERM=xterm LC_ALL="C" $LAUNCH_FILE "$@" ;; terminator|xfce4-terminal|mate-terminal) # consumes all arguments after -x - exec $TERMINAL_NAME -x env TERM=xterm $LAUNCH_FILE "$@" + exec $TERMINAL_NAME -x env TERM=xterm LC_ALL="C" $LAUNCH_FILE "$@" ;; gnome-terminal) # consumes all arguments after -- - exec $TERMINAL_NAME -- env TERM=xterm $LAUNCH_FILE "$@" + exec $TERMINAL_NAME -- env TERM=xterm LC_ALL="C" $LAUNCH_FILE "$@" ;; kitty) # consumes all trailing arguments - exec $TERMINAL_NAME env TERM=xterm $LAUNCH_FILE "$@" + exec $TERMINAL_NAME env TERM=xterm LC_ALL="C" $LAUNCH_FILE "$@" ;; *) # If we don't know the terminal, just try to run it in the current shell. # If THAT fails, launch with no output. - env TERM=xterm $LAUNCH_FILE "$@" + env TERM=xterm LC_ALL="C" $LAUNCH_FILE "$@" if [ $? -eq 127 ]; then - exec $LAUNCH_FILE --no-terminal "$@" + exec LC_ALL="C" $LAUNCH_FILE --no-terminal "$@" fi esac ## terminal isn't executable; fallback to current shell or no terminal else echo "The '$TERMINAL_NAME' terminal isn't executable. SMAPI might be running in a sandbox or the system might be misconfigured? Falling back to current shell." - env TERM=xterm $LAUNCH_FILE "$@" + env TERM=xterm LC_ALL="C" $LAUNCH_FILE "$@" if [ $? -eq 127 ]; then - exec $LAUNCH_FILE --no-terminal "$@" + exec LC_ALL="C" $LAUNCH_FILE --no-terminal "$@" fi fi fi diff --git a/src/SMAPI.Installer/assets/windows-install.bat b/src/SMAPI.Installer/assets/windows-install.bat index 2cc54e80..2cd98554 100644 --- a/src/SMAPI.Installer/assets/windows-install.bat +++ b/src/SMAPI.Installer/assets/windows-install.bat @@ -1,8 +1,8 @@ @echo off -echo %~dp0 | findstr /C:"%TEMP%" 1>nul +echo "%~dp0" | findstr /C:"%TEMP%" 1>nul if not errorlevel 1 ( echo Oops! It looks like you're running the installer from inside a zip file. Make sure you unzip the download first. pause ) else ( - start /WAIT /B ./internal/windows-install.exe + start /WAIT /B internal\windows-install.exe ) diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs index 34149209..0357fe6b 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs @@ -28,8 +28,10 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework ** Public methods *********/ /// Get all spawnable items. + /// The item types to fetch (or null for any type). + /// Whether to include flavored variants like "Sunflower Honey". [SuppressMessage("ReSharper", "AccessToModifiedClosure", Justification = "TryCreate invokes the lambda immediately.")] - public IEnumerable GetAll() + public IEnumerable GetAll(ItemType[] itemTypes = null, bool includeVariants = true) { // // @@ -41,222 +43,246 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework // // - IEnumerable GetAllRaw() { - // get tools - for (int q = Tool.stone; q <= Tool.iridium; q++) - { - int quality = q; + HashSet types = itemTypes?.Any() == true ? new HashSet(itemTypes) : null; + bool ShouldGet(ItemType type) => types == null || types.Contains(type); - yield return this.TryCreate(ItemType.Tool, ToolFactory.axe, _ => ToolFactory.getToolFromDescription(ToolFactory.axe, quality)); - yield return this.TryCreate(ItemType.Tool, ToolFactory.hoe, _ => ToolFactory.getToolFromDescription(ToolFactory.hoe, quality)); - yield return this.TryCreate(ItemType.Tool, ToolFactory.pickAxe, _ => ToolFactory.getToolFromDescription(ToolFactory.pickAxe, quality)); - yield return this.TryCreate(ItemType.Tool, ToolFactory.wateringCan, _ => ToolFactory.getToolFromDescription(ToolFactory.wateringCan, quality)); - if (quality != Tool.iridium) - yield return this.TryCreate(ItemType.Tool, ToolFactory.fishingRod, _ => ToolFactory.getToolFromDescription(ToolFactory.fishingRod, quality)); + // get tools + if (ShouldGet(ItemType.Tool)) + { + for (int q = Tool.stone; q <= Tool.iridium; q++) + { + int quality = q; + + yield return this.TryCreate(ItemType.Tool, ToolFactory.axe, _ => ToolFactory.getToolFromDescription(ToolFactory.axe, quality)); + yield return this.TryCreate(ItemType.Tool, ToolFactory.hoe, _ => ToolFactory.getToolFromDescription(ToolFactory.hoe, quality)); + yield return this.TryCreate(ItemType.Tool, ToolFactory.pickAxe, _ => ToolFactory.getToolFromDescription(ToolFactory.pickAxe, quality)); + yield return this.TryCreate(ItemType.Tool, ToolFactory.wateringCan, _ => ToolFactory.getToolFromDescription(ToolFactory.wateringCan, quality)); + if (quality != Tool.iridium) + yield return this.TryCreate(ItemType.Tool, ToolFactory.fishingRod, _ => ToolFactory.getToolFromDescription(ToolFactory.fishingRod, quality)); + } + yield return this.TryCreate(ItemType.Tool, this.CustomIDOffset, _ => new MilkPail()); // these don't have any sort of ID, so we'll just assign some arbitrary ones + yield return this.TryCreate(ItemType.Tool, this.CustomIDOffset + 1, _ => new Shears()); + yield return this.TryCreate(ItemType.Tool, this.CustomIDOffset + 2, _ => new Pan()); + yield return this.TryCreate(ItemType.Tool, this.CustomIDOffset + 3, _ => new Wand()); } - yield return this.TryCreate(ItemType.Tool, this.CustomIDOffset, _ => new MilkPail()); // these don't have any sort of ID, so we'll just assign some arbitrary ones - yield return this.TryCreate(ItemType.Tool, this.CustomIDOffset + 1, _ => new Shears()); - yield return this.TryCreate(ItemType.Tool, this.CustomIDOffset + 2, _ => new Pan()); - yield return this.TryCreate(ItemType.Tool, this.CustomIDOffset + 3, _ => new Wand()); // clothing + if (ShouldGet(ItemType.Clothing)) { - // items - HashSet clothingIds = new HashSet(); - foreach (int id in Game1.clothingInformation.Keys) - { - if (id < 0) - continue; // placeholder data for character customization clothing below - - clothingIds.Add(id); + foreach (int id in this.GetShirtIds()) yield return this.TryCreate(ItemType.Clothing, id, p => new Clothing(p.ID)); - } - - // character customization shirts (some shirts in this range have no data, but game has special logic to handle them) - for (int id = 1000; id <= 1111; id++) - { - if (!clothingIds.Contains(id)) - yield return this.TryCreate(ItemType.Clothing, id, p => new Clothing(p.ID)); - } } // wallpapers - for (int id = 0; id < 112; id++) - yield return this.TryCreate(ItemType.Wallpaper, id, p => new Wallpaper(p.ID) { Category = SObject.furnitureCategory }); + if (ShouldGet(ItemType.Wallpaper)) + { + for (int id = 0; id < 112; id++) + yield return this.TryCreate(ItemType.Wallpaper, id, p => new Wallpaper(p.ID) { Category = SObject.furnitureCategory }); + } // flooring - for (int id = 0; id < 56; id++) - yield return this.TryCreate(ItemType.Flooring, id, p => new Wallpaper(p.ID, isFloor: true) { Category = SObject.furnitureCategory }); + if (ShouldGet(ItemType.Flooring)) + { + for (int id = 0; id < 56; id++) + yield return this.TryCreate(ItemType.Flooring, id, p => new Wallpaper(p.ID, isFloor: true) { Category = SObject.furnitureCategory }); + } // equipment - foreach (int id in this.TryLoad("Data\\Boots").Keys) - yield return this.TryCreate(ItemType.Boots, id, p => new Boots(p.ID)); - foreach (int id in this.TryLoad("Data\\hats").Keys) - yield return this.TryCreate(ItemType.Hat, id, p => new Hat(p.ID)); + if (ShouldGet(ItemType.Boots)) + { + foreach (int id in this.TryLoad("Data\\Boots").Keys) + yield return this.TryCreate(ItemType.Boots, id, p => new Boots(p.ID)); + } + if (ShouldGet(ItemType.Hat)) + { + foreach (int id in this.TryLoad("Data\\hats").Keys) + yield return this.TryCreate(ItemType.Hat, id, p => new Hat(p.ID)); + } // weapons - foreach (int id in this.TryLoad("Data\\weapons").Keys) + if (ShouldGet(ItemType.Weapon)) { - yield return this.TryCreate(ItemType.Weapon, id, p => (p.ID >= 32 && p.ID <= 34) - ? (Item)new Slingshot(p.ID) - : new MeleeWeapon(p.ID) - ); + foreach (int id in this.TryLoad("Data\\weapons").Keys) + { + yield return this.TryCreate(ItemType.Weapon, id, p => (p.ID >= 32 && p.ID <= 34) + ? (Item)new Slingshot(p.ID) + : new MeleeWeapon(p.ID) + ); + } } // furniture - foreach (int id in this.TryLoad("Data\\Furniture").Keys) - yield return this.TryCreate(ItemType.Furniture, id, p => Furniture.GetFurnitureInstance(p.ID)); + if (ShouldGet(ItemType.Furniture)) + { + foreach (int id in this.TryLoad("Data\\Furniture").Keys) + yield return this.TryCreate(ItemType.Furniture, id, p => Furniture.GetFurnitureInstance(p.ID)); + } // craftables - foreach (int id in Game1.bigCraftablesInformation.Keys) - yield return this.TryCreate(ItemType.BigCraftable, id, p => new SObject(Vector2.Zero, p.ID)); + if (ShouldGet(ItemType.BigCraftable)) + { + foreach (int id in Game1.bigCraftablesInformation.Keys) + yield return this.TryCreate(ItemType.BigCraftable, id, p => new SObject(Vector2.Zero, p.ID)); + } // objects - foreach (int id in Game1.objectInformation.Keys) + if (ShouldGet(ItemType.Object) || ShouldGet(ItemType.Ring)) { - string[] fields = Game1.objectInformation[id]?.Split('/'); - - // secret notes - if (id == 79) + foreach (int id in Game1.objectInformation.Keys) { - foreach (int secretNoteId in this.TryLoad("Data\\SecretNotes").Keys) + string[] fields = Game1.objectInformation[id]?.Split('/'); + + // secret notes + if (id == 79) { - yield return this.TryCreate(ItemType.Object, this.CustomIDOffset + secretNoteId, _ => + if (ShouldGet(ItemType.Object)) { - SObject note = new SObject(79, 1); - note.name = $"{note.name} #{secretNoteId}"; - return note; - }); + foreach (int secretNoteId in this.TryLoad("Data\\SecretNotes").Keys) + { + yield return this.TryCreate(ItemType.Object, this.CustomIDOffset + secretNoteId, _ => + { + SObject note = new SObject(79, 1); + note.name = $"{note.name} #{secretNoteId}"; + return note; + }); + } + } } - } - // ring - else if (id != 801 && fields?.Length >= 4 && fields[3] == "Ring") // 801 = wedding ring, which isn't an equippable ring - yield return this.TryCreate(ItemType.Ring, id, p => new Ring(p.ID)); - - // item - else - { - // spawn main item - SObject item = null; - yield return this.TryCreate(ItemType.Object, id, p => + // ring + else if (id != 801 && fields?.Length >= 4 && fields[3] == "Ring") // 801 = wedding ring, which isn't an equippable ring { - return item = (p.ID == 812 // roe - ? new ColoredObject(p.ID, 1, Color.White) - : new SObject(p.ID, 1) - ); - }); - if (item == null) - continue; + if (ShouldGet(ItemType.Ring)) + yield return this.TryCreate(ItemType.Ring, id, p => new Ring(p.ID)); + } - // flavored items - switch (item.Category) + // item + else if (ShouldGet(ItemType.Object)) { - // fruit products - case SObject.FruitsCategory: - // wine - yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 2 + item.ParentSheetIndex, _ => new SObject(348, 1) + // spawn main item + SObject item = null; + yield return this.TryCreate(ItemType.Object, id, p => + { + return item = (p.ID == 812 // roe + ? new ColoredObject(p.ID, 1, Color.White) + : new SObject(p.ID, 1) + ); + }); + if (item == null) + continue; + + // flavored items + if (includeVariants) + { + switch (item.Category) { - Name = $"{item.Name} Wine", - Price = item.Price * 3, - preserve = { SObject.PreserveType.Wine }, - preservedParentSheetIndex = { item.ParentSheetIndex } - }); - - // jelly - yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 3 + item.ParentSheetIndex, _ => new SObject(344, 1) - { - Name = $"{item.Name} Jelly", - Price = 50 + item.Price * 2, - preserve = { SObject.PreserveType.Jelly }, - preservedParentSheetIndex = { item.ParentSheetIndex } - }); - break; - - // vegetable products - case SObject.VegetableCategory: - // juice - yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 4 + item.ParentSheetIndex, _ => new SObject(350, 1) - { - Name = $"{item.Name} Juice", - Price = (int)(item.Price * 2.25d), - preserve = { SObject.PreserveType.Juice }, - preservedParentSheetIndex = { item.ParentSheetIndex } - }); - - // pickled - yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 5 + item.ParentSheetIndex, _ => new SObject(342, 1) - { - Name = $"Pickled {item.Name}", - Price = 50 + item.Price * 2, - preserve = { SObject.PreserveType.Pickle }, - preservedParentSheetIndex = { item.ParentSheetIndex } - }); - break; - - // flower honey - case SObject.flowersCategory: - yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 5 + item.ParentSheetIndex, _ => - { - SObject honey = new SObject(Vector2.Zero, 340, $"{item.Name} Honey", false, true, false, false) - { - Name = $"{item.Name} Honey", - preservedParentSheetIndex = { item.ParentSheetIndex } - }; - honey.Price += item.Price * 2; - return honey; - }); - break; - - // roe and aged roe (derived from FishPond.GetFishProduce) - case SObject.sellAtFishShopCategory when item.ParentSheetIndex == 812: - { - this.GetRoeContextTagLookups(out HashSet simpleTags, out List> complexTags); - - foreach (var pair in Game1.objectInformation) - { - // get input - SObject input = this.TryCreate(ItemType.Object, pair.Key, p => new SObject(p.ID, 1))?.Item as SObject; - var inputTags = input?.GetContextTags(); - if (inputTags?.Any() != true) - continue; - - // check if roe-producing fish - if (!inputTags.Any(tag => simpleTags.Contains(tag)) && !complexTags.Any(set => set.All(tag => input.HasContextTag(tag)))) - continue; - - // yield roe - SObject roe = null; - Color color = this.GetRoeColor(input); - yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 7 + item.ParentSheetIndex, _ => + // fruit products + case SObject.FruitsCategory: + // wine + yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 2 + item.ParentSheetIndex, _ => new SObject(348, 1) { - roe = new ColoredObject(812, 1, color) - { - name = $"{input.Name} Roe", - preserve = { Value = SObject.PreserveType.Roe }, - preservedParentSheetIndex = { Value = input.ParentSheetIndex } - }; - roe.Price += input.Price / 2; - return roe; + Name = $"{item.Name} Wine", + Price = item.Price * 3, + preserve = { SObject.PreserveType.Wine }, + preservedParentSheetIndex = { item.ParentSheetIndex } }); - // aged roe - if (roe != null && pair.Key != 698) // aged sturgeon roe is caviar, which is a separate item + // jelly + yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 3 + item.ParentSheetIndex, _ => new SObject(344, 1) { - yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 7 + item.ParentSheetIndex, _ => new ColoredObject(447, 1, color) + Name = $"{item.Name} Jelly", + Price = 50 + item.Price * 2, + preserve = { SObject.PreserveType.Jelly }, + preservedParentSheetIndex = { item.ParentSheetIndex } + }); + break; + + // vegetable products + case SObject.VegetableCategory: + // juice + yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 4 + item.ParentSheetIndex, _ => new SObject(350, 1) + { + Name = $"{item.Name} Juice", + Price = (int)(item.Price * 2.25d), + preserve = { SObject.PreserveType.Juice }, + preservedParentSheetIndex = { item.ParentSheetIndex } + }); + + // pickled + yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 5 + item.ParentSheetIndex, _ => new SObject(342, 1) + { + Name = $"Pickled {item.Name}", + Price = 50 + item.Price * 2, + preserve = { SObject.PreserveType.Pickle }, + preservedParentSheetIndex = { item.ParentSheetIndex } + }); + break; + + // flower honey + case SObject.flowersCategory: + yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 5 + item.ParentSheetIndex, _ => + { + SObject honey = new SObject(Vector2.Zero, 340, $"{item.Name} Honey", false, true, false, false) { - name = $"Aged {input.Name} Roe", - Category = -27, - preserve = { Value = SObject.PreserveType.AgedRoe }, - preservedParentSheetIndex = { Value = input.ParentSheetIndex }, - Price = roe.Price * 2 - }); + Name = $"{item.Name} Honey", + preservedParentSheetIndex = { item.ParentSheetIndex } + }; + honey.Price += item.Price * 2; + return honey; + }); + break; + + // roe and aged roe (derived from FishPond.GetFishProduce) + case SObject.sellAtFishShopCategory when item.ParentSheetIndex == 812: + { + this.GetRoeContextTagLookups(out HashSet simpleTags, out List> complexTags); + + foreach (var pair in Game1.objectInformation) + { + // get input + SObject input = this.TryCreate(ItemType.Object, pair.Key, p => new SObject(p.ID, 1))?.Item as SObject; + var inputTags = input?.GetContextTags(); + if (inputTags?.Any() != true) + continue; + + // check if roe-producing fish + if (!inputTags.Any(tag => simpleTags.Contains(tag)) && !complexTags.Any(set => set.All(tag => input.HasContextTag(tag)))) + continue; + + // yield roe + SObject roe = null; + Color color = this.GetRoeColor(input); + yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 7 + item.ParentSheetIndex, _ => + { + roe = new ColoredObject(812, 1, color) + { + name = $"{input.Name} Roe", + preserve = { Value = SObject.PreserveType.Roe }, + preservedParentSheetIndex = { Value = input.ParentSheetIndex } + }; + roe.Price += input.Price / 2; + return roe; + }); + + // aged roe + if (roe != null && pair.Key != 698) // aged sturgeon roe is caviar, which is a separate item + { + yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 7 + item.ParentSheetIndex, _ => new ColoredObject(447, 1, color) + { + name = $"Aged {input.Name} Roe", + Category = -27, + preserve = { Value = SObject.PreserveType.AgedRoe }, + preservedParentSheetIndex = { Value = input.ParentSheetIndex }, + Price = roe.Price * 2 + }); + } + } } - } + break; } - break; + } } } } @@ -333,5 +359,43 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework ? new Color(61, 55, 42) : (TailoringMenu.GetDyeColor(fish) ?? Color.Orange); } + + /// Get valid shirt IDs. + /// + /// Shirts have a possible range of 1000–1999, but not all of those IDs are valid. There are two sets of IDs: + /// + /// + /// + /// Shirts which exist in . + /// + /// + /// Shirts with a dynamic ID and no entry in . These automatically + /// use the generic shirt entry with ID -1 and are mapped to a calculated position in the + /// Characters/Farmer/shirts spritesheet. There's no constant we can use, but some known valid + /// ranges are 1000–1111 (used in for the customization screen and + /// 1000–1127 (used in and ). + /// Based on the spritesheet, the max valid ID is 1299. + /// + /// + /// + private IEnumerable GetShirtIds() + { + // defined shirt items + foreach (int id in Game1.clothingInformation.Keys) + { + if (id < 0) + continue; // placeholder data for character customization clothing below + + yield return id; + } + + // dynamic shirts + HashSet clothingIds = new HashSet(Game1.clothingInformation.Keys); + for (int id = 1000; id <= 1299; id++) + { + if (!clothingIds.Contains(id)) + yield return id; + } + } } } diff --git a/src/SMAPI.Mods.ConsoleCommands/manifest.json b/src/SMAPI.Mods.ConsoleCommands/manifest.json index e9513309..1781c40d 100644 --- a/src/SMAPI.Mods.ConsoleCommands/manifest.json +++ b/src/SMAPI.Mods.ConsoleCommands/manifest.json @@ -1,9 +1,9 @@ { "Name": "Console Commands", "Author": "SMAPI", - "Version": "3.10.1", + "Version": "3.11.0", "Description": "Adds SMAPI console commands that let you manipulate the game.", "UniqueID": "SMAPI.ConsoleCommands", "EntryDll": "ConsoleCommands.dll", - "MinimumApiVersion": "3.10.1" + "MinimumApiVersion": "3.11.0" } diff --git a/src/SMAPI.Mods.ErrorHandler/manifest.json b/src/SMAPI.Mods.ErrorHandler/manifest.json index b0b29379..82e6152d 100644 --- a/src/SMAPI.Mods.ErrorHandler/manifest.json +++ b/src/SMAPI.Mods.ErrorHandler/manifest.json @@ -1,9 +1,9 @@ { "Name": "Error Handler", "Author": "SMAPI", - "Version": "3.10.1", + "Version": "3.11.0", "Description": "Handles some common vanilla errors to log more useful info or avoid breaking the game.", "UniqueID": "SMAPI.ErrorHandler", "EntryDll": "ErrorHandler.dll", - "MinimumApiVersion": "3.10.1" + "MinimumApiVersion": "3.11.0" } diff --git a/src/SMAPI.Mods.SaveBackup/manifest.json b/src/SMAPI.Mods.SaveBackup/manifest.json index fea9b62e..6042dee4 100644 --- a/src/SMAPI.Mods.SaveBackup/manifest.json +++ b/src/SMAPI.Mods.SaveBackup/manifest.json @@ -1,9 +1,9 @@ { "Name": "Save Backup", "Author": "SMAPI", - "Version": "3.10.1", + "Version": "3.11.0", "Description": "Automatically backs up all your saves once per day into its folder.", "UniqueID": "SMAPI.SaveBackup", "EntryDll": "SaveBackup.dll", - "MinimumApiVersion": "3.10.1" + "MinimumApiVersion": "3.11.0" } diff --git a/src/SMAPI.Toolkit/Framework/ModData/ModDataField.cs b/src/SMAPI.Toolkit/Framework/ModData/ModDataField.cs index 6658d219..b02be3e4 100644 --- a/src/SMAPI.Toolkit/Framework/ModData/ModDataField.cs +++ b/src/SMAPI.Toolkit/Framework/ModData/ModDataField.cs @@ -66,7 +66,7 @@ namespace StardewModdingAPI.Toolkit.Framework.ModData { // update key case ModDataFieldKey.UpdateKey: - return manifest.UpdateKeys != null && manifest.UpdateKeys.Any(p => !string.IsNullOrWhiteSpace(p)); + return manifest.UpdateKeys.Any(p => !string.IsNullOrWhiteSpace(p)); // non-manifest fields case ModDataFieldKey.StatusReasonPhrase: diff --git a/src/SMAPI.Toolkit/Framework/ModScanning/ModFolder.cs b/src/SMAPI.Toolkit/Framework/ModScanning/ModFolder.cs index d0df09a1..825b98e5 100644 --- a/src/SMAPI.Toolkit/Framework/ModScanning/ModFolder.cs +++ b/src/SMAPI.Toolkit/Framework/ModScanning/ModFolder.cs @@ -69,7 +69,7 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning public IEnumerable GetUpdateKeys(Manifest manifest) { return - (manifest.UpdateKeys ?? new string[0]) + manifest.UpdateKeys .Where(p => !string.IsNullOrWhiteSpace(p)) .ToArray(); } diff --git a/src/SMAPI.Toolkit/Serialization/Models/Manifest.cs b/src/SMAPI.Toolkit/Serialization/Models/Manifest.cs index 99e85cbd..46b654a5 100644 --- a/src/SMAPI.Toolkit/Serialization/Models/Manifest.cs +++ b/src/SMAPI.Toolkit/Serialization/Models/Manifest.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Runtime.Serialization; using Newtonsoft.Json; using StardewModdingAPI.Toolkit.Serialization.Converters; @@ -70,5 +71,14 @@ namespace StardewModdingAPI.Toolkit.Serialization.Models this.UpdateKeys = new string[0]; this.ContentPackFor = new ManifestContentPackFor { UniqueID = contentPackFor }; } + + /// Normalize the model after it's deserialized. + /// The deserialization context. + [OnDeserialized] + public void OnDeserialized(StreamingContext context) + { + this.Dependencies ??= new IManifestDependency[0]; + this.UpdateKeys ??= new string[0]; + } } } diff --git a/src/SMAPI.Web/wwwroot/schemas/content-patcher.json b/src/SMAPI.Web/wwwroot/schemas/content-patcher.json index 49900da6..aac4ff0f 100644 --- a/src/SMAPI.Web/wwwroot/schemas/content-patcher.json +++ b/src/SMAPI.Web/wwwroot/schemas/content-patcher.json @@ -4,16 +4,19 @@ "title": "Content Patcher content pack", "description": "Content Patcher content file for mods", "@documentationUrl": "https://github.com/Pathoschild/StardewMods/tree/develop/ContentPatcher#readme", - "type": "object", + "allowComments": true, + "allowTrailingCommas": true, + + "type": "object", "properties": { "Format": { "title": "Format version", "description": "The format version. You should always use the latest version to enable the latest features and avoid obsolete behavior.", "type": "string", - "const": "1.22.0", + "const": "1.23.0", "@errorMessages": { - "const": "Incorrect value '@value'. This should be set to the latest format version, currently '1.22.0'." + "const": "Incorrect value '@value'. This should be set to the latest format version, currently '1.23.0'." } }, "ConfigSchema": { diff --git a/src/SMAPI.Web/wwwroot/schemas/i18n.json b/src/SMAPI.Web/wwwroot/schemas/i18n.json index 493ad213..36c04890 100644 --- a/src/SMAPI.Web/wwwroot/schemas/i18n.json +++ b/src/SMAPI.Web/wwwroot/schemas/i18n.json @@ -4,14 +4,17 @@ "title": "SMAPI i18n file", "description": "A translation file for a SMAPI mod or content pack.", "@documentationUrl": "https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Translation", - "type": "object", + "allowComments": true, + "allowTrailingCommas": true, + + "type": "object", "properties": { "$schema": { "title": "Schema", "description": "A reference to this JSON schema. Not part of the actual format, but useful for validation tools.", "type": "string", - "const": "https://smapi.io/schemas/manifest.json" + "const": "https://smapi.io/schemas/i18n.json" } }, diff --git a/src/SMAPI.Web/wwwroot/schemas/manifest.json b/src/SMAPI.Web/wwwroot/schemas/manifest.json index 0b265201..05698ba2 100644 --- a/src/SMAPI.Web/wwwroot/schemas/manifest.json +++ b/src/SMAPI.Web/wwwroot/schemas/manifest.json @@ -4,6 +4,10 @@ "title": "SMAPI manifest", "description": "Manifest file for a SMAPI mod or content pack", "@documentationUrl": "https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Manifest", + + "allowComments": true, + "allowTrailingCommas": true, + "type": "object", "properties": { "Name": { diff --git a/src/SMAPI/Constants.cs b/src/SMAPI/Constants.cs index 2e476208..9e93551c 100644 --- a/src/SMAPI/Constants.cs +++ b/src/SMAPI/Constants.cs @@ -61,7 +61,7 @@ namespace StardewModdingAPI internal static int? LogScreenId { get; set; } /// SMAPI's current raw semantic version. - internal static string RawApiVersion = "3.10.1"; + internal static string RawApiVersion = "3.11.0"; } /// Contains SMAPI's constants and assumptions. @@ -322,31 +322,42 @@ namespace StardewModdingAPI /// Get the name of the save folder, if any. private static string GetSaveFolderName() { - // save not available - if (Context.LoadStage == LoadStage.None) - return null; - - // get basic info - string saveName = Game1.GetSaveGameName(set_value: false); - ulong saveID = Context.LoadStage == LoadStage.SaveParsed - ? SaveGame.loaded.uniqueIDForThisGame - : Game1.uniqueIDForThisGame; - - // build folder name - return $"{new string(saveName.Where(char.IsLetterOrDigit).ToArray())}_{saveID}"; + return Constants.GetSaveFolder()?.Name; } /// Get the path to the current save folder, if any. private static string GetSaveFolderPathIfExists() { - string folderName = Constants.GetSaveFolderName(); - if (folderName == null) + DirectoryInfo saveFolder = Constants.GetSaveFolder(); + return saveFolder?.Exists == true + ? saveFolder.FullName + : null; + } + + /// Get the current save folder, if any. + private static DirectoryInfo GetSaveFolder() + { + // save not available + if (Context.LoadStage == LoadStage.None) return null; - string path = Path.Combine(Constants.SavesPath, folderName); - return Directory.Exists(path) - ? path - : null; + // get basic info + string rawSaveName = Game1.GetSaveGameName(set_value: false); + ulong saveID = Context.LoadStage == LoadStage.SaveParsed + ? SaveGame.loaded.uniqueIDForThisGame + : Game1.uniqueIDForThisGame; + + // get best match (accounting for rare case where folder name isn't sanitized) + DirectoryInfo folder = null; + foreach (string saveName in new[] { rawSaveName, new string(rawSaveName.Where(char.IsLetterOrDigit).ToArray()) }) + { + folder = new DirectoryInfo(Path.Combine(Constants.SavesPath, $"{saveName}_{saveID}")); + if (folder.Exists) + return folder; + } + + // if save doesn't exist yet, return the default one we expect to be created + return folder; } } } diff --git a/src/SMAPI/Events/FurnitureListChangedEventArgs.cs b/src/SMAPI/Events/FurnitureListChangedEventArgs.cs new file mode 100644 index 00000000..683f4620 --- /dev/null +++ b/src/SMAPI/Events/FurnitureListChangedEventArgs.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using StardewValley; +using StardewValley.Objects; + +namespace StardewModdingAPI.Events +{ + /// Event arguments for a event. + public class FurnitureListChangedEventArgs : EventArgs + { + /********* + ** Accessors + *********/ + /// The location which changed. + public GameLocation Location { get; } + + /// The furniture added to the location. + public IEnumerable Added { get; } + + /// The furniture removed from the location. + public IEnumerable Removed { get; } + + /// Whether this is the location containing the local player. + public bool IsCurrentLocation => object.ReferenceEquals(this.Location, Game1.player?.currentLocation); + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The location which changed. + /// The furniture added to the location. + /// The furniture removed from the location. + internal FurnitureListChangedEventArgs(GameLocation location, IEnumerable added, IEnumerable removed) + { + this.Location = location; + this.Added = added.ToArray(); + this.Removed = removed.ToArray(); + } + } +} diff --git a/src/SMAPI/Events/IWorldEvents.cs b/src/SMAPI/Events/IWorldEvents.cs index 9569a57b..c023e1f0 100644 --- a/src/SMAPI/Events/IWorldEvents.cs +++ b/src/SMAPI/Events/IWorldEvents.cs @@ -28,5 +28,8 @@ namespace StardewModdingAPI.Events /// Raised after terrain features (like floors and trees) are added or removed in a location. event EventHandler TerrainFeatureListChanged; + + /// Raised after furniture are added or removed in a location. + event EventHandler FurnitureListChanged; } } diff --git a/src/SMAPI/Framework/Events/EventManager.cs b/src/SMAPI/Framework/Events/EventManager.cs index f4abfffe..dfc289ed 100644 --- a/src/SMAPI/Framework/Events/EventManager.cs +++ b/src/SMAPI/Framework/Events/EventManager.cs @@ -162,6 +162,9 @@ namespace StardewModdingAPI.Framework.Events /// Raised after terrain features (like floors and trees) are added or removed in a location. public readonly ManagedEvent TerrainFeatureListChanged; + /// Raised after furniture are added or removed in a location. + public readonly ManagedEvent FurnitureListChanged; + /**** ** Specialized ****/ @@ -238,6 +241,7 @@ namespace StardewModdingAPI.Framework.Events this.ObjectListChanged = ManageEventOf(nameof(IModEvents.World), nameof(IWorldEvents.ObjectListChanged)); this.ChestInventoryChanged = ManageEventOf(nameof(IModEvents.World), nameof(IWorldEvents.ChestInventoryChanged)); this.TerrainFeatureListChanged = ManageEventOf(nameof(IModEvents.World), nameof(IWorldEvents.TerrainFeatureListChanged)); + this.FurnitureListChanged = ManageEventOf(nameof(IModEvents.World), nameof(IWorldEvents.FurnitureListChanged)); this.LoadStageChanged = ManageEventOf(nameof(IModEvents.Specialized), nameof(ISpecializedEvents.LoadStageChanged)); this.UnvalidatedUpdateTicking = ManageEventOf(nameof(IModEvents.Specialized), nameof(ISpecializedEvents.UnvalidatedUpdateTicking), isPerformanceCritical: true); diff --git a/src/SMAPI/Framework/Events/ModWorldEvents.cs b/src/SMAPI/Framework/Events/ModWorldEvents.cs index 21b1b664..f4c40abc 100644 --- a/src/SMAPI/Framework/Events/ModWorldEvents.cs +++ b/src/SMAPI/Framework/Events/ModWorldEvents.cs @@ -65,6 +65,13 @@ namespace StardewModdingAPI.Framework.Events remove => this.EventManager.TerrainFeatureListChanged.Remove(value); } + /// Raised after furniture are added or removed in a location. + public event EventHandler FurnitureListChanged + { + add => this.EventManager.FurnitureListChanged.Add(value, this.Mod); + remove => this.EventManager.FurnitureListChanged.Remove(value); + } + /********* ** Public methods diff --git a/src/SMAPI/Framework/ModLoading/ModMetadata.cs b/src/SMAPI/Framework/ModLoading/ModMetadata.cs index 17e6d59a..0ace084f 100644 --- a/src/SMAPI/Framework/ModLoading/ModMetadata.cs +++ b/src/SMAPI/Framework/ModLoading/ModMetadata.cs @@ -195,7 +195,10 @@ namespace StardewModdingAPI.Framework.ModLoading /// public IEnumerable GetUpdateKeys(bool validOnly = false) { - foreach (string rawKey in this.Manifest?.UpdateKeys ?? new string[0]) + if (!this.HasManifest()) + yield break; + + foreach (string rawKey in this.Manifest.UpdateKeys) { UpdateKey updateKey = UpdateKey.Parse(rawKey); if (updateKey.LooksValid || !validOnly) @@ -251,16 +254,19 @@ namespace StardewModdingAPI.Framework.ModLoading { var ids = new Dictionary(StringComparer.OrdinalIgnoreCase); - // yield dependencies - if (this.Manifest?.Dependencies != null) + if (this.HasManifest()) { - foreach (var entry in this.Manifest?.Dependencies) - ids[entry.UniqueID] = entry.IsRequired; - } + // yield dependencies + foreach (IManifestDependency entry in this.Manifest.Dependencies) + { + if (!string.IsNullOrWhiteSpace(entry.UniqueID)) + ids[entry.UniqueID] = entry.IsRequired; + } - // yield content pack parent - if (this.Manifest?.ContentPackFor?.UniqueID != null) - ids[this.Manifest.ContentPackFor.UniqueID] = true; + // yield content pack parent + if (!string.IsNullOrWhiteSpace(this.Manifest.ContentPackFor?.UniqueID)) + ids[this.Manifest.ContentPackFor.UniqueID] = true; + } return ids; } diff --git a/src/SMAPI/Framework/ModLoading/ModResolver.cs b/src/SMAPI/Framework/ModLoading/ModResolver.cs index c70820e4..2f506571 100644 --- a/src/SMAPI/Framework/ModLoading/ModResolver.cs +++ b/src/SMAPI/Framework/ModLoading/ModResolver.cs @@ -82,7 +82,7 @@ namespace StardewModdingAPI.Framework.ModLoading // get update URLs List updateUrls = new List(); - foreach (string key in mod.Manifest.UpdateKeys ?? new string[0]) + foreach (string key in mod.Manifest.UpdateKeys) { string url = getUpdateUrl(key); if (url != null) @@ -173,7 +173,7 @@ namespace StardewModdingAPI.Framework.ModLoading if (string.IsNullOrWhiteSpace(mod.Manifest.Name)) missingFields.Add(nameof(IManifest.Name)); - if (mod.Manifest.Version == null || mod.Manifest.Version.ToString() == "0.0") + if (mod.Manifest.Version == null || mod.Manifest.Version.ToString() == "0.0.0") missingFields.Add(nameof(IManifest.Version)); if (string.IsNullOrWhiteSpace(mod.Manifest.UniqueID)) missingFields.Add(nameof(IManifest.UniqueID)); @@ -188,6 +188,28 @@ namespace StardewModdingAPI.Framework.ModLoading // validate ID format if (!PathUtilities.IsSlug(mod.Manifest.UniqueID)) mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, "its manifest specifies an invalid ID (IDs must only contain letters, numbers, underscores, periods, or hyphens)."); + + // validate dependencies + foreach (var dependency in mod.Manifest.Dependencies) + { + // null dependency + if (dependency == null) + { + mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its manifest has a null entry under {nameof(IManifest.Dependencies)}."); + continue; + } + + // missing ID + if (string.IsNullOrWhiteSpace(dependency.UniqueID)) + { + mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its manifest has a {nameof(IManifest.Dependencies)} entry with no {nameof(IManifestDependency.UniqueID)} field."); + continue; + } + + // invalid ID + if (!PathUtilities.IsSlug(dependency.UniqueID)) + mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its manifest has a {nameof(IManifest.Dependencies)} entry with an invalid {nameof(IManifestDependency.UniqueID)} field (IDs must only contain letters, numbers, underscores, periods, or hyphens)."); + } } // validate IDs are unique diff --git a/src/SMAPI/Framework/Models/SConfig.cs b/src/SMAPI/Framework/Models/SConfig.cs index a71bafd9..10bf9f94 100644 --- a/src/SMAPI/Framework/Models/SConfig.cs +++ b/src/SMAPI/Framework/Models/SConfig.cs @@ -22,7 +22,7 @@ namespace StardewModdingAPI.Framework.Models [nameof(VerboseLogging)] = false, [nameof(LogNetworkTraffic)] = false, [nameof(RewriteMods)] = true, - [nameof(AggressiveMemoryOptimizations)] = true + [nameof(AggressiveMemoryOptimizations)] = false }; /// The default values for , to log changes if different. diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs index 5862b112..c3285979 100644 --- a/src/SMAPI/Framework/SCore.cs +++ b/src/SMAPI/Framework/SCore.cs @@ -11,6 +11,9 @@ using System.Security; using System.Text; using System.Threading; using System.Threading.Tasks; +#if SMAPI_FOR_WINDOWS +using Microsoft.Win32; +#endif using Microsoft.Xna.Framework; #if SMAPI_FOR_XNA using System.Windows.Forms; @@ -294,7 +297,14 @@ namespace StardewModdingAPI.Framework } finally { - this.Dispose(); + try + { + this.Dispose(); + } + catch (Exception ex) + { + this.Monitor.Log($"The game ended, but SMAPI wasn't able to dispose correctly. Technical details: {ex}", LogLevel.Error); + } } } @@ -376,6 +386,9 @@ namespace StardewModdingAPI.Framework mods = resolver.ProcessDependencies(mods, modDatabase).ToArray(); this.LoadMods(mods, this.Toolkit.JsonHelper, this.ContentCore, modDatabase); + // check for software likely to cause issues + this.CheckForSoftwareConflicts(); + // check for updates this.CheckForUpdatesAsync(mods); } @@ -914,6 +927,10 @@ namespace StardewModdingAPI.Framework // terrain features changed if (locState.TerrainFeatures.IsChanged) events.TerrainFeatureListChanged.Raise(new TerrainFeatureListChangedEventArgs(location, locState.TerrainFeatures.Added, locState.TerrainFeatures.Removed)); + + // furniture changed + if (locState.Furniture.IsChanged) + events.FurnitureListChanged.Raise(new FurnitureListChangedEventArgs(location, locState.Furniture.Added, locState.Furniture.Removed)); } } @@ -1247,6 +1264,55 @@ namespace StardewModdingAPI.Framework this.LogManager.SetConsoleTitle(consoleTitle); } + /// Log a warning if software known to cause issues is installed. + private void CheckForSoftwareConflicts() + { +#if SMAPI_FOR_WINDOWS + this.Monitor.Log("Checking for known software conflicts..."); + + try + { + string[] registryKeys = { @"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall", @"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" }; + + string[] installedNames = registryKeys + .SelectMany(registryKey => + { + using RegistryKey key = Registry.LocalMachine.OpenSubKey(registryKey); + if (key == null) + return new string[0]; + + return key + .GetSubKeyNames() + .Select(subkeyName => + { + using RegistryKey subkey = key.OpenSubKey(subkeyName); + string displayName = (string)subkey?.GetValue("DisplayName"); + string displayVersion = (string)subkey?.GetValue("DisplayVersion"); + + if (displayName != null && displayVersion != null && displayName.EndsWith($" {displayVersion}")) + displayName = displayName.Substring(0, displayName.Length - displayVersion.Length - 1); + + return displayName; + }) + .ToArray(); + }) + .Where(name => name != null && (name.Contains("MSI Afterburner") || name.Contains("RivaTuner"))) + .Distinct() + .OrderBy(name => name) + .ToArray(); + + if (installedNames.Any()) + this.Monitor.Log($" Found {string.Join(" and ", installedNames)} installed, which can conflict with SMAPI. If you experience errors or crashes, try disabling that software or adding an exception for SMAPI / Stardew Valley."); + else + this.Monitor.Log(" None found!"); + } + catch (Exception ex) + { + this.Monitor.Log($"Failed when checking for conflicting software. Technical details:\n{ex}"); + } +#endif + } + /// Asynchronously check for a new version of SMAPI and any installed mods, and print alerts to the console if an update is available. /// The mods to include in the update check (if eligible). private void CheckForUpdatesAsync(IModMetadata[] mods) @@ -1593,19 +1659,16 @@ namespace StardewModdingAPI.Framework // validate dependencies // Although dependencies are validated before mods are loaded, a dependency may have failed to load. - if (mod.Manifest.Dependencies?.Any() == true) + foreach (IManifestDependency dependency in mod.Manifest.Dependencies.Where(p => p.IsRequired)) { - foreach (IManifestDependency dependency in mod.Manifest.Dependencies.Where(p => p.IsRequired)) + if (this.ModRegistry.Get(dependency.UniqueID) == null) { - if (this.ModRegistry.Get(dependency.UniqueID) == null) - { - string dependencyName = mods - .FirstOrDefault(otherMod => otherMod.HasID(dependency.UniqueID)) - ?.DisplayName ?? dependency.UniqueID; - errorReasonPhrase = $"it needs the '{dependencyName}' mod, which couldn't be loaded."; - failReason = ModFailReason.MissingDependencies; - return false; - } + string dependencyName = mods + .FirstOrDefault(otherMod => otherMod.HasID(dependency.UniqueID)) + ?.DisplayName ?? dependency.UniqueID; + errorReasonPhrase = $"it needs the '{dependencyName}' mod, which couldn't be loaded."; + failReason = ModFailReason.MissingDependencies; + return false; } } diff --git a/src/SMAPI/Framework/StateTracking/LocationTracker.cs b/src/SMAPI/Framework/StateTracking/LocationTracker.cs index 519fe8f4..6d3a62bb 100644 --- a/src/SMAPI/Framework/StateTracking/LocationTracker.cs +++ b/src/SMAPI/Framework/StateTracking/LocationTracker.cs @@ -48,6 +48,9 @@ namespace StardewModdingAPI.Framework.StateTracking /// Tracks added or removed terrain features. public IDictionaryWatcher TerrainFeaturesWatcher { get; } + /// Tracks added or removed furniture. + public ICollectionWatcher FurnitureWatcher { get; } + /// Tracks items added or removed to chests. public IDictionary ChestWatchers { get; } = new Dictionary(); @@ -68,6 +71,7 @@ namespace StardewModdingAPI.Framework.StateTracking this.NpcsWatcher = WatcherFactory.ForNetCollection(location.characters); this.ObjectsWatcher = WatcherFactory.ForNetDictionary(location.netObjects); this.TerrainFeaturesWatcher = WatcherFactory.ForNetDictionary(location.terrainFeatures); + this.FurnitureWatcher = WatcherFactory.ForNetCollection(location.furniture); this.Watchers.AddRange(new IWatcher[] { @@ -76,7 +80,8 @@ namespace StardewModdingAPI.Framework.StateTracking this.LargeTerrainFeaturesWatcher, this.NpcsWatcher, this.ObjectsWatcher, - this.TerrainFeaturesWatcher + this.TerrainFeaturesWatcher, + this.FurnitureWatcher }); this.UpdateChestWatcherList(added: location.Objects.Pairs, removed: new KeyValuePair[0]); diff --git a/src/SMAPI/Framework/StateTracking/Snapshots/LocationSnapshot.cs b/src/SMAPI/Framework/StateTracking/Snapshots/LocationSnapshot.cs index 6ae52fd0..6c9cc4f5 100644 --- a/src/SMAPI/Framework/StateTracking/Snapshots/LocationSnapshot.cs +++ b/src/SMAPI/Framework/StateTracking/Snapshots/LocationSnapshot.cs @@ -34,6 +34,9 @@ namespace StardewModdingAPI.Framework.StateTracking.Snapshots /// Tracks added or removed terrain features. public SnapshotListDiff> TerrainFeatures { get; } = new SnapshotListDiff>(); + /// Tracks added or removed furniture. + public SnapshotListDiff Furniture { get; } = new SnapshotListDiff(); + /// Tracks changed chest inventories. public IDictionary ChestItems { get; } = new Dictionary(); @@ -59,6 +62,7 @@ namespace StardewModdingAPI.Framework.StateTracking.Snapshots this.Npcs.Update(watcher.NpcsWatcher); this.Objects.Update(watcher.ObjectsWatcher); this.TerrainFeatures.Update(watcher.TerrainFeaturesWatcher); + this.Furniture.Update(watcher.FurnitureWatcher); // chest inventories this.ChestItems.Clear(); diff --git a/src/SMAPI/Metadata/CoreAssetPropagator.cs b/src/SMAPI/Metadata/CoreAssetPropagator.cs index 623c65d5..5641f90f 100644 --- a/src/SMAPI/Metadata/CoreAssetPropagator.cs +++ b/src/SMAPI/Metadata/CoreAssetPropagator.cs @@ -233,6 +233,16 @@ namespace StardewModdingAPI.Metadata return true; } + case "buildings\\houses_paintmask": // Farm + { + bool removedFromCache = this.RemoveFromPaintMaskCache(key); + + Farm farm = Game1.getFarm(); + farm?.ApplyHousePaint(); + + return removedFromCache || farm != null; + } + /**** ** Content\Characters\Farmer ****/ @@ -613,7 +623,7 @@ namespace StardewModdingAPI.Metadata return this.ReloadFarmAnimalSprites(content, key); if (this.IsInFolder(key, "Buildings")) - return this.ReloadBuildings(content, key); + return this.ReloadBuildings(key); if (this.KeyStartsWith(key, "LooseSprites\\Fence")) return this.ReloadFenceTextures(key); @@ -717,28 +727,39 @@ namespace StardewModdingAPI.Metadata } /// Reload building textures. - /// The content manager through which to reload the asset. /// The asset key to reload. /// Returns whether any textures were reloaded. - private bool ReloadBuildings(LocalizedContentManager content, string key) + private bool ReloadBuildings(string key) { - // get buildings + // get paint mask info + const string paintMaskSuffix = "_PaintMask"; + bool isPaintMask = key.EndsWith(paintMaskSuffix, StringComparison.OrdinalIgnoreCase); + + // get building type string type = Path.GetFileName(key); + if (isPaintMask) + type = type.Substring(0, type.Length - paintMaskSuffix.Length); + + // get buildings Building[] buildings = this.GetLocations(buildingInteriors: false) .OfType() .SelectMany(p => p.buildings) .Where(p => p.buildingType.Value == type) .ToArray(); - // reload buildings + // remove from paint mask cache + bool removedFromCache = this.RemoveFromPaintMaskCache(key); + + // reload textures if (buildings.Any()) { - Lazy texture = new Lazy(() => content.Load(key)); foreach (Building building in buildings) - building.texture = texture; + building.resetTexture(); + return true; } - return false; + + return removedFromCache; } /// Reload map seat textures. @@ -1295,5 +1316,18 @@ namespace StardewModdingAPI.Metadata // else just (re)load it from the main content manager return this.MainContentManager.Load(key); } + + /// Remove a case-insensitive key from the paint mask cache. + /// The paint mask asset key. + private bool RemoveFromPaintMaskCache(string key) + { + // make cache case-insensitive + // This is needed for cache invalidation since mods may specify keys with a different capitalization + if (!object.ReferenceEquals(BuildingPainter.paintMaskLookup.Comparer, StringComparer.OrdinalIgnoreCase)) + BuildingPainter.paintMaskLookup = new Dictionary>>(BuildingPainter.paintMaskLookup, StringComparer.OrdinalIgnoreCase); + + // remove key from cache + return BuildingPainter.paintMaskLookup.Remove(key); + } } }