summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/SMAPI.Installer/assets/unix-launcher.sh46
-rw-r--r--src/SMAPI.Installer/assets/windows-install.bat4
-rw-r--r--src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs408
-rw-r--r--src/SMAPI.Mods.ConsoleCommands/manifest.json4
-rw-r--r--src/SMAPI.Mods.ErrorHandler/manifest.json4
-rw-r--r--src/SMAPI.Mods.SaveBackup/manifest.json4
-rw-r--r--src/SMAPI.Toolkit/Framework/ModData/ModDataField.cs2
-rw-r--r--src/SMAPI.Toolkit/Framework/ModScanning/ModFolder.cs2
-rw-r--r--src/SMAPI.Toolkit/Serialization/Models/Manifest.cs10
-rw-r--r--src/SMAPI.Web/wwwroot/schemas/content-patcher.json9
-rw-r--r--src/SMAPI.Web/wwwroot/schemas/i18n.json7
-rw-r--r--src/SMAPI.Web/wwwroot/schemas/manifest.json4
-rw-r--r--src/SMAPI/Constants.cs43
-rw-r--r--src/SMAPI/Events/FurnitureListChangedEventArgs.cs42
-rw-r--r--src/SMAPI/Events/IWorldEvents.cs3
-rw-r--r--src/SMAPI/Framework/Events/EventManager.cs4
-rw-r--r--src/SMAPI/Framework/Events/ModWorldEvents.cs7
-rw-r--r--src/SMAPI/Framework/ModLoading/ModMetadata.cs24
-rw-r--r--src/SMAPI/Framework/ModLoading/ModResolver.cs26
-rw-r--r--src/SMAPI/Framework/Models/SConfig.cs2
-rw-r--r--src/SMAPI/Framework/SCore.cs87
-rw-r--r--src/SMAPI/Framework/StateTracking/LocationTracker.cs7
-rw-r--r--src/SMAPI/Framework/StateTracking/Snapshots/LocationSnapshot.cs4
-rw-r--r--src/SMAPI/Metadata/CoreAssetPropagator.cs50
24 files changed, 557 insertions, 246 deletions
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
*********/
/// <summary>Get all spawnable items.</summary>
+ /// <param name="itemTypes">The item types to fetch (or null for any type).</param>
+ /// <param name="includeVariants">Whether to include flavored variants like "Sunflower Honey".</param>
[SuppressMessage("ReSharper", "AccessToModifiedClosure", Justification = "TryCreate invokes the lambda immediately.")]
- public IEnumerable<SearchableItem> GetAll()
+ public IEnumerable<SearchableItem> GetAll(ItemType[] itemTypes = null, bool includeVariants = true)
{
//
//
@@ -41,222 +43,246 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework
//
//
-
IEnumerable<SearchableItem> GetAllRaw()
{
+ HashSet<ItemType> types = itemTypes?.Any() == true ? new HashSet<ItemType>(itemTypes) : null;
+ bool ShouldGet(ItemType type) => types == null || types.Contains(type);
+
// get tools
- for (int q = Tool.stone; q <= Tool.iridium; q++)
+ if (ShouldGet(ItemType.Tool))
{
- 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));
+ 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<int> clothingIds = new HashSet<int>();
- 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<int, string>("Data\\Boots").Keys)
- yield return this.TryCreate(ItemType.Boots, id, p => new Boots(p.ID));
- foreach (int id in this.TryLoad<int, string>("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<int, string>("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<int, string>("Data\\hats").Keys)
+ yield return this.TryCreate(ItemType.Hat, id, p => new Hat(p.ID));
+ }
// weapons
- foreach (int id in this.TryLoad<int, string>("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<int, string>("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<int, string>("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<int, string>("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<int, string>("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<int, string>("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;
-
- // flavored items
- switch (item.Category)
+ if (ShouldGet(ItemType.Ring))
+ yield return this.TryCreate(ItemType.Ring, id, p => new Ring(p.ID));
+ }
+
+ // 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)
- {
- 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:
+ // 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)
{
- this.GetRoeContextTagLookups(out HashSet<string> simpleTags, out List<List<string>> complexTags);
+ // fruit products
+ case SObject.FruitsCategory:
+ // wine
+ yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 2 + item.ParentSheetIndex, _ => new SObject(348, 1)
+ {
+ Name = $"{item.Name} Wine",
+ Price = item.Price * 3,
+ preserve = { SObject.PreserveType.Wine },
+ preservedParentSheetIndex = { item.ParentSheetIndex }
+ });
- 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, _ =>
+ // 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)
{
- roe = new ColoredObject(812, 1, color)
+ 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 = $"{input.Name} Roe",
- preserve = { Value = SObject.PreserveType.Roe },
- preservedParentSheetIndex = { Value = input.ParentSheetIndex }
+ Name = $"{item.Name} Honey",
+ preservedParentSheetIndex = { item.ParentSheetIndex }
};
- roe.Price += input.Price / 2;
- return roe;
+ honey.Price += item.Price * 2;
+ return honey;
});
+ break;
- // aged roe
- if (roe != null && pair.Key != 698) // aged sturgeon roe is caviar, which is a separate item
+ // roe and aged roe (derived from FishPond.GetFishProduce)
+ case SObject.sellAtFishShopCategory when item.ParentSheetIndex == 812:
{
- yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 7 + item.ParentSheetIndex, _ => new ColoredObject(447, 1, color)
+ this.GetRoeContextTagLookups(out HashSet<string> simpleTags, out List<List<string>> complexTags);
+
+ foreach (var pair in Game1.objectInformation)
{
- name = $"Aged {input.Name} Roe",
- Category = -27,
- preserve = { Value = SObject.PreserveType.AgedRoe },
- preservedParentSheetIndex = { Value = input.ParentSheetIndex },
- Price = roe.Price * 2
- });
+ // 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);
}
+
+ /// <summary>Get valid shirt IDs.</summary>
+ /// <remarks>
+ /// Shirts have a possible range of 1000–1999, but not all of those IDs are valid. There are two sets of IDs:
+ ///
+ /// <list type="number">
+ /// <item>
+ /// Shirts which exist in <see cref="Game1.clothingInformation"/>.
+ /// </item>
+ /// <item>
+ /// Shirts with a dynamic ID and no entry in <see cref="Game1.clothingInformation"/>. These automatically
+ /// use the generic shirt entry with ID <c>-1</c> and are mapped to a calculated position in the
+ /// <c>Characters/Farmer/shirts</c> spritesheet. There's no constant we can use, but some known valid
+ /// ranges are 1000–1111 (used in <see cref="Farmer.changeShirt"/> for the customization screen and
+ /// 1000–1127 (used in <see cref="Utility.getShopStock"/> and <see cref="GameLocation.sandyShopStock"/>).
+ /// Based on the spritesheet, the max valid ID is 1299.
+ /// </item>
+ /// </list>
+ /// </remarks>
+ private IEnumerable<int> 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<int> clothingIds = new HashSet<int>(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<string> 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 };
}
+
+ /// <summary>Normalize the model after it's deserialized.</summary>
+ /// <param name="context">The deserialization context.</param>
+ [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; }
/// <summary>SMAPI's current raw semantic version.</summary>
- internal static string RawApiVersion = "3.10.1";
+ internal static string RawApiVersion = "3.11.0";
}
/// <summary>Contains SMAPI's constants and assumptions.</summary>
@@ -322,31 +322,42 @@ namespace StardewModdingAPI
/// <summary>Get the name of the save folder, if any.</summary>
private static string GetSaveFolderName()
{
+ return Constants.GetSaveFolder()?.Name;
+ }
+
+ /// <summary>Get the path to the current save folder, if any.</summary>
+ private static string GetSaveFolderPathIfExists()
+ {
+ DirectoryInfo saveFolder = Constants.GetSaveFolder();
+ return saveFolder?.Exists == true
+ ? saveFolder.FullName
+ : null;
+ }
+
+ /// <summary>Get the current save folder, if any.</summary>
+ private static DirectoryInfo GetSaveFolder()
+ {
// save not available
if (Context.LoadStage == LoadStage.None)
return null;
// get basic info
- string saveName = Game1.GetSaveGameName(set_value: false);
+ string rawSaveName = 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}";
- }
-
- /// <summary>Get the path to the current save folder, if any.</summary>
- private static string GetSaveFolderPathIfExists()
- {
- string folderName = Constants.GetSaveFolderName();
- if (folderName == null)
- return null;
+ // 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;
+ }
- string path = Path.Combine(Constants.SavesPath, folderName);
- return Directory.Exists(path)
- ? path
- : null;
+ // 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
+{
+ /// <summary>Event arguments for a <see cref="IWorldEvents.FurnitureListChanged"/> event.</summary>
+ public class FurnitureListChangedEventArgs : EventArgs
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The location which changed.</summary>
+ public GameLocation Location { get; }
+
+ /// <summary>The furniture added to the location.</summary>
+ public IEnumerable<Furniture> Added { get; }
+
+ /// <summary>The furniture removed from the location.</summary>
+ public IEnumerable<Furniture> Removed { get; }
+
+ /// <summary>Whether this is the location containing the local player.</summary>
+ public bool IsCurrentLocation => object.ReferenceEquals(this.Location, Game1.player?.currentLocation);
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="location">The location which changed.</param>
+ /// <param name="added">The furniture added to the location.</param>
+ /// <param name="removed">The furniture removed from the location.</param>
+ internal FurnitureListChangedEventArgs(GameLocation location, IEnumerable<Furniture> added, IEnumerable<Furniture> 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
/// <summary>Raised after terrain features (like floors and trees) are added or removed in a location.</summary>
event EventHandler<TerrainFeatureListChangedEventArgs> TerrainFeatureListChanged;
+
+ /// <summary>Raised after furniture are added or removed in a location.</summary>
+ event EventHandler<FurnitureListChangedEventArgs> 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
/// <summary>Raised after terrain features (like floors and trees) are added or removed in a location.</summary>
public readonly ManagedEvent<TerrainFeatureListChangedEventArgs> TerrainFeatureListChanged;
+ /// <summary>Raised after furniture are added or removed in a location.</summary>
+ public readonly ManagedEvent<FurnitureListChangedEventArgs> FurnitureListChanged;
+
/****
** Specialized
****/
@@ -238,6 +241,7 @@ namespace StardewModdingAPI.Framework.Events
this.ObjectListChanged = ManageEventOf<ObjectListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.ObjectListChanged));
this.ChestInventoryChanged = ManageEventOf<ChestInventoryChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.ChestInventoryChanged));
this.TerrainFeatureListChanged = ManageEventOf<TerrainFeatureListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.TerrainFeatureListChanged));
+ this.FurnitureListChanged = ManageEventOf<FurnitureListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.FurnitureListChanged));
this.LoadStageChanged = ManageEventOf<LoadStageChangedEventArgs>(nameof(IModEvents.Specialized), nameof(ISpecializedEvents.LoadStageChanged));
this.UnvalidatedUpdateTicking = ManageEventOf<UnvalidatedUpdateTickingEventArgs>(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);
}
+ /// <summary>Raised after furniture are added or removed in a location.</summary>
+ public event EventHandler<FurnitureListChangedEventArgs> 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
/// <inheritdoc />
public IEnumerable<UpdateKey> 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,17 +254,20 @@ namespace StardewModdingAPI.Framework.ModLoading
{
var ids = new Dictionary<string, bool>(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 (!string.IsNullOrWhiteSpace(this.Manifest.ContentPackFor?.UniqueID))
+ ids[this.Manifest.ContentPackFor.UniqueID] = true;
}
- // yield content pack parent
- if (this.Manifest?.ContentPackFor?.UniqueID != null)
- 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<string> updateUrls = new List<string>();
- 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
};
/// <summary>The default values for <see cref="SuppressUpdateChecks"/>, to log changes if different.</summary>
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);
}
+ /// <summary>Log a warning if software known to cause issues is installed.</summary>
+ 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
+ }
+
/// <summary>Asynchronously check for a new version of SMAPI and any installed mods, and print alerts to the console if an update is available.</summary>
/// <param name="mods">The mods to include in the update check (if eligible).</param>
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
/// <summary>Tracks added or removed terrain features.</summary>
public IDictionaryWatcher<Vector2, TerrainFeature> TerrainFeaturesWatcher { get; }
+ /// <summary>Tracks added or removed furniture.</summary>
+ public ICollectionWatcher<Furniture> FurnitureWatcher { get; }
+
/// <summary>Tracks items added or removed to chests.</summary>
public IDictionary<Vector2, ChestTracker> ChestWatchers { get; } = new Dictionary<Vector2, ChestTracker>();
@@ -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<Vector2, SObject>[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
/// <summary>Tracks added or removed terrain features.</summary>
public SnapshotListDiff<KeyValuePair<Vector2, TerrainFeature>> TerrainFeatures { get; } = new SnapshotListDiff<KeyValuePair<Vector2, TerrainFeature>>();
+ /// <summary>Tracks added or removed furniture.</summary>
+ public SnapshotListDiff<Furniture> Furniture { get; } = new SnapshotListDiff<Furniture>();
+
/// <summary>Tracks changed chest inventories.</summary>
public IDictionary<Chest, SnapshotItemListDiff> ChestItems { get; } = new Dictionary<Chest, SnapshotItemListDiff>();
@@ -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
}
/// <summary>Reload building textures.</summary>
- /// <param name="content">The content manager through which to reload the asset.</param>
/// <param name="key">The asset key to reload.</param>
/// <returns>Returns whether any textures were reloaded.</returns>
- 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<BuildableGameLocation>()
.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<Texture2D> texture = new Lazy<Texture2D>(() => content.Load<Texture2D>(key));
foreach (Building building in buildings)
- building.texture = texture;
+ building.resetTexture();
+
return true;
}
- return false;
+
+ return removedFromCache;
}
/// <summary>Reload map seat textures.</summary>
@@ -1295,5 +1316,18 @@ namespace StardewModdingAPI.Metadata
// else just (re)load it from the main content manager
return this.MainContentManager.Load<Texture2D>(key);
}
+
+ /// <summary>Remove a case-insensitive key from the paint mask cache.</summary>
+ /// <param name="key">The paint mask asset key.</param>
+ 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<string, List<List<int>>>(BuildingPainter.paintMaskLookup, StringComparer.OrdinalIgnoreCase);
+
+ // remove key from cache
+ return BuildingPainter.paintMaskLookup.Remove(key);
+ }
}
}