Merge branch 'develop' into stable

This commit is contained in:
Jesse Plamondon-Willard 2021-07-09 22:30:13 -04:00
commit 8f96a97f07
No known key found for this signature in database
GPG Key ID: CF8B1456B3E29F49
27 changed files with 589 additions and 254 deletions

View File

@ -1,7 +1,7 @@
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> <Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup> <PropertyGroup>
<!--set general build properties --> <!--set general build properties -->
<Version>3.10.1</Version> <Version>3.11.0</Version>
<Product>SMAPI</Product> <Product>SMAPI</Product>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<AssemblySearchPaths>$(AssemblySearchPaths);{GAC}</AssemblySearchPaths> <AssemblySearchPaths>$(AssemblySearchPaths);{GAC}</AssemblySearchPaths>

View File

@ -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). * 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 ## 3.10.1
Released 03 May 2021 for Stardew Valley 1.5.4 or later. Released 03 May 2021 for Stardew Valley 1.5.4 or later.

View File

@ -324,7 +324,7 @@ To do that:
<GamePath>PATH_HERE</GamePath> <GamePath>PATH_HERE</GamePath>
</PropertyGroup> </PropertyGroup>
``` ```
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 The configuration will check your custom path first, then fall back to the default paths (so it'll
still compile on a different computer). still compile on a different computer).

View File

@ -43,8 +43,34 @@ if [ "$UNAME" == "Darwin" ]; then
cp -p StardewValley.bin.osx StardewModdingAPI.bin.osx cp -p StardewValley.bin.osx StardewModdingAPI.bin.osx
fi 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 # launch SMAPI
open -a Terminal ./StardewModdingAPI.bin.osx "$@" LC_ALL="C" ./StardewModdingAPI.bin.osx "$@"
else else
# choose binary file to launch # choose binary file to launch
LAUNCH_FILE="" LAUNCH_FILE=""
@ -79,44 +105,44 @@ else
terminal|termite) terminal|termite)
# consumes only one argument after -e # consumes only one argument after -e
# options containing space characters are unsupported # 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) xterm|konsole|alacritty)
# consumes all arguments after -e # 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) terminator|xfce4-terminal|mate-terminal)
# consumes all arguments after -x # 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) gnome-terminal)
# consumes all arguments after -- # consumes all arguments after --
exec $TERMINAL_NAME -- env TERM=xterm $LAUNCH_FILE "$@" exec $TERMINAL_NAME -- env TERM=xterm LC_ALL="C" $LAUNCH_FILE "$@"
;; ;;
kitty) kitty)
# consumes all trailing arguments # 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 we don't know the terminal, just try to run it in the current shell.
# If THAT fails, launch with no output. # If THAT fails, launch with no output.
env TERM=xterm $LAUNCH_FILE "$@" env TERM=xterm LC_ALL="C" $LAUNCH_FILE "$@"
if [ $? -eq 127 ]; then if [ $? -eq 127 ]; then
exec $LAUNCH_FILE --no-terminal "$@" exec LC_ALL="C" $LAUNCH_FILE --no-terminal "$@"
fi fi
esac esac
## terminal isn't executable; fallback to current shell or no terminal ## terminal isn't executable; fallback to current shell or no terminal
else 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." 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 if [ $? -eq 127 ]; then
exec $LAUNCH_FILE --no-terminal "$@" exec LC_ALL="C" $LAUNCH_FILE --no-terminal "$@"
fi fi
fi fi
fi fi

View File

@ -1,8 +1,8 @@
@echo off @echo off
echo %~dp0 | findstr /C:"%TEMP%" 1>nul echo "%~dp0" | findstr /C:"%TEMP%" 1>nul
if not errorlevel 1 ( 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. echo Oops! It looks like you're running the installer from inside a zip file. Make sure you unzip the download first.
pause pause
) else ( ) else (
start /WAIT /B ./internal/windows-install.exe start /WAIT /B internal\windows-install.exe
) )

View File

@ -28,8 +28,10 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework
** Public methods ** Public methods
*********/ *********/
/// <summary>Get all spawnable items.</summary> /// <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.")] [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() IEnumerable<SearchableItem> GetAllRaw()
{ {
// get tools HashSet<ItemType> types = itemTypes?.Any() == true ? new HashSet<ItemType>(itemTypes) : null;
for (int q = Tool.stone; q <= Tool.iridium; q++) bool ShouldGet(ItemType type) => types == null || types.Contains(type);
{
int quality = q;
yield return this.TryCreate(ItemType.Tool, ToolFactory.axe, _ => ToolFactory.getToolFromDescription(ToolFactory.axe, quality)); // get tools
yield return this.TryCreate(ItemType.Tool, ToolFactory.hoe, _ => ToolFactory.getToolFromDescription(ToolFactory.hoe, quality)); if (ShouldGet(ItemType.Tool))
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)); for (int q = Tool.stone; q <= Tool.iridium; q++)
if (quality != Tool.iridium) {
yield return this.TryCreate(ItemType.Tool, ToolFactory.fishingRod, _ => ToolFactory.getToolFromDescription(ToolFactory.fishingRod, quality)); 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 // clothing
if (ShouldGet(ItemType.Clothing))
{ {
// items foreach (int id in this.GetShirtIds())
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);
yield return this.TryCreate(ItemType.Clothing, id, p => new Clothing(p.ID)); 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 // wallpapers
for (int id = 0; id < 112; id++) if (ShouldGet(ItemType.Wallpaper))
yield return this.TryCreate(ItemType.Wallpaper, id, p => new Wallpaper(p.ID) { Category = SObject.furnitureCategory }); {
for (int id = 0; id < 112; id++)
yield return this.TryCreate(ItemType.Wallpaper, id, p => new Wallpaper(p.ID) { Category = SObject.furnitureCategory });
}
// flooring // flooring
for (int id = 0; id < 56; id++) if (ShouldGet(ItemType.Flooring))
yield return this.TryCreate(ItemType.Flooring, id, p => new Wallpaper(p.ID, isFloor: true) { Category = SObject.furnitureCategory }); {
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 // equipment
foreach (int id in this.TryLoad<int, string>("Data\\Boots").Keys) if (ShouldGet(ItemType.Boots))
yield return this.TryCreate(ItemType.Boots, id, p => new Boots(p.ID)); {
foreach (int id in this.TryLoad<int, string>("Data\\hats").Keys) foreach (int id in this.TryLoad<int, string>("Data\\Boots").Keys)
yield return this.TryCreate(ItemType.Hat, id, p => new Hat(p.ID)); 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 // 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) foreach (int id in this.TryLoad<int, string>("Data\\weapons").Keys)
? (Item)new Slingshot(p.ID) {
: new MeleeWeapon(p.ID) yield return this.TryCreate(ItemType.Weapon, id, p => (p.ID >= 32 && p.ID <= 34)
); ? (Item)new Slingshot(p.ID)
: new MeleeWeapon(p.ID)
);
}
} }
// furniture // furniture
foreach (int id in this.TryLoad<int, string>("Data\\Furniture").Keys) if (ShouldGet(ItemType.Furniture))
yield return this.TryCreate(ItemType.Furniture, id, p => Furniture.GetFurnitureInstance(p.ID)); {
foreach (int id in this.TryLoad<int, string>("Data\\Furniture").Keys)
yield return this.TryCreate(ItemType.Furniture, id, p => Furniture.GetFurnitureInstance(p.ID));
}
// craftables // craftables
foreach (int id in Game1.bigCraftablesInformation.Keys) if (ShouldGet(ItemType.BigCraftable))
yield return this.TryCreate(ItemType.BigCraftable, id, p => new SObject(Vector2.Zero, p.ID)); {
foreach (int id in Game1.bigCraftablesInformation.Keys)
yield return this.TryCreate(ItemType.BigCraftable, id, p => new SObject(Vector2.Zero, p.ID));
}
// objects // objects
foreach (int id in Game1.objectInformation.Keys) if (ShouldGet(ItemType.Object) || ShouldGet(ItemType.Ring))
{ {
string[] fields = Game1.objectInformation[id]?.Split('/'); foreach (int id in Game1.objectInformation.Keys)
// secret notes
if (id == 79)
{ {
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); foreach (int secretNoteId in this.TryLoad<int, string>("Data\\SecretNotes").Keys)
note.name = $"{note.name} #{secretNoteId}"; {
return note; yield return this.TryCreate(ItemType.Object, this.CustomIDOffset + secretNoteId, _ =>
}); {
SObject note = new SObject(79, 1);
note.name = $"{note.name} #{secretNoteId}";
return note;
});
}
}
} }
}
// ring // ring
else if (id != 801 && fields?.Length >= 4 && fields[3] == "Ring") // 801 = wedding ring, which isn't an equippable 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 =>
{ {
return item = (p.ID == 812 // roe if (ShouldGet(ItemType.Ring))
? new ColoredObject(p.ID, 1, Color.White) yield return this.TryCreate(ItemType.Ring, id, p => new Ring(p.ID));
: new SObject(p.ID, 1) }
);
});
if (item == null)
continue;
// flavored items // item
switch (item.Category) else if (ShouldGet(ItemType.Object))
{ {
// fruit products // spawn main item
case SObject.FruitsCategory: SObject item = null;
// wine yield return this.TryCreate(ItemType.Object, id, p =>
yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 2 + item.ParentSheetIndex, _ => new SObject(348, 1) {
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", // fruit products
Price = item.Price * 3, case SObject.FruitsCategory:
preserve = { SObject.PreserveType.Wine }, // wine
preservedParentSheetIndex = { item.ParentSheetIndex } yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 2 + item.ParentSheetIndex, _ => new SObject(348, 1)
});
// 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<string> simpleTags, out List<List<string>> 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 = $"{item.Name} Wine",
{ Price = item.Price * 3,
name = $"{input.Name} Roe", preserve = { SObject.PreserveType.Wine },
preserve = { Value = SObject.PreserveType.Roe }, preservedParentSheetIndex = { item.ParentSheetIndex }
preservedParentSheetIndex = { Value = input.ParentSheetIndex }
};
roe.Price += input.Price / 2;
return roe;
}); });
// aged roe // jelly
if (roe != null && pair.Key != 698) // aged sturgeon roe is caviar, which is a separate item 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", Name = $"{item.Name} Honey",
Category = -27, preservedParentSheetIndex = { item.ParentSheetIndex }
preserve = { Value = SObject.PreserveType.AgedRoe }, };
preservedParentSheetIndex = { Value = input.ParentSheetIndex }, honey.Price += item.Price * 2;
Price = roe.Price * 2 return honey;
}); });
break;
// roe and aged roe (derived from FishPond.GetFishProduce)
case SObject.sellAtFishShopCategory when item.ParentSheetIndex == 812:
{
this.GetRoeContextTagLookups(out HashSet<string> simpleTags, out List<List<string>> 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) ? new Color(61, 55, 42)
: (TailoringMenu.GetDyeColor(fish) ?? Color.Orange); : (TailoringMenu.GetDyeColor(fish) ?? Color.Orange);
} }
/// <summary>Get valid shirt IDs.</summary>
/// <remarks>
/// Shirts have a possible range of 10001999, 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 10001111 (used in <see cref="Farmer.changeShirt"/> for the customization screen and
/// 10001127 (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;
}
}
} }
} }

View File

@ -1,9 +1,9 @@
{ {
"Name": "Console Commands", "Name": "Console Commands",
"Author": "SMAPI", "Author": "SMAPI",
"Version": "3.10.1", "Version": "3.11.0",
"Description": "Adds SMAPI console commands that let you manipulate the game.", "Description": "Adds SMAPI console commands that let you manipulate the game.",
"UniqueID": "SMAPI.ConsoleCommands", "UniqueID": "SMAPI.ConsoleCommands",
"EntryDll": "ConsoleCommands.dll", "EntryDll": "ConsoleCommands.dll",
"MinimumApiVersion": "3.10.1" "MinimumApiVersion": "3.11.0"
} }

View File

@ -1,9 +1,9 @@
{ {
"Name": "Error Handler", "Name": "Error Handler",
"Author": "SMAPI", "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.", "Description": "Handles some common vanilla errors to log more useful info or avoid breaking the game.",
"UniqueID": "SMAPI.ErrorHandler", "UniqueID": "SMAPI.ErrorHandler",
"EntryDll": "ErrorHandler.dll", "EntryDll": "ErrorHandler.dll",
"MinimumApiVersion": "3.10.1" "MinimumApiVersion": "3.11.0"
} }

View File

@ -1,9 +1,9 @@
{ {
"Name": "Save Backup", "Name": "Save Backup",
"Author": "SMAPI", "Author": "SMAPI",
"Version": "3.10.1", "Version": "3.11.0",
"Description": "Automatically backs up all your saves once per day into its folder.", "Description": "Automatically backs up all your saves once per day into its folder.",
"UniqueID": "SMAPI.SaveBackup", "UniqueID": "SMAPI.SaveBackup",
"EntryDll": "SaveBackup.dll", "EntryDll": "SaveBackup.dll",
"MinimumApiVersion": "3.10.1" "MinimumApiVersion": "3.11.0"
} }

View File

@ -66,7 +66,7 @@ namespace StardewModdingAPI.Toolkit.Framework.ModData
{ {
// update key // update key
case ModDataFieldKey.UpdateKey: 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 // non-manifest fields
case ModDataFieldKey.StatusReasonPhrase: case ModDataFieldKey.StatusReasonPhrase:

View File

@ -69,7 +69,7 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning
public IEnumerable<string> GetUpdateKeys(Manifest manifest) public IEnumerable<string> GetUpdateKeys(Manifest manifest)
{ {
return return
(manifest.UpdateKeys ?? new string[0]) manifest.UpdateKeys
.Where(p => !string.IsNullOrWhiteSpace(p)) .Where(p => !string.IsNullOrWhiteSpace(p))
.ToArray(); .ToArray();
} }

View File

@ -1,4 +1,5 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Runtime.Serialization;
using Newtonsoft.Json; using Newtonsoft.Json;
using StardewModdingAPI.Toolkit.Serialization.Converters; using StardewModdingAPI.Toolkit.Serialization.Converters;
@ -70,5 +71,14 @@ namespace StardewModdingAPI.Toolkit.Serialization.Models
this.UpdateKeys = new string[0]; this.UpdateKeys = new string[0];
this.ContentPackFor = new ManifestContentPackFor { UniqueID = contentPackFor }; 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];
}
} }
} }

View File

@ -4,16 +4,19 @@
"title": "Content Patcher content pack", "title": "Content Patcher content pack",
"description": "Content Patcher content file for mods", "description": "Content Patcher content file for mods",
"@documentationUrl": "https://github.com/Pathoschild/StardewMods/tree/develop/ContentPatcher#readme", "@documentationUrl": "https://github.com/Pathoschild/StardewMods/tree/develop/ContentPatcher#readme",
"type": "object",
"allowComments": true,
"allowTrailingCommas": true,
"type": "object",
"properties": { "properties": {
"Format": { "Format": {
"title": "Format version", "title": "Format version",
"description": "The format version. You should always use the latest version to enable the latest features and avoid obsolete behavior.", "description": "The format version. You should always use the latest version to enable the latest features and avoid obsolete behavior.",
"type": "string", "type": "string",
"const": "1.22.0", "const": "1.23.0",
"@errorMessages": { "@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": { "ConfigSchema": {

View File

@ -4,14 +4,17 @@
"title": "SMAPI i18n file", "title": "SMAPI i18n file",
"description": "A translation file for a SMAPI mod or content pack.", "description": "A translation file for a SMAPI mod or content pack.",
"@documentationUrl": "https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Translation", "@documentationUrl": "https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Translation",
"type": "object",
"allowComments": true,
"allowTrailingCommas": true,
"type": "object",
"properties": { "properties": {
"$schema": { "$schema": {
"title": "Schema", "title": "Schema",
"description": "A reference to this JSON schema. Not part of the actual format, but useful for validation tools.", "description": "A reference to this JSON schema. Not part of the actual format, but useful for validation tools.",
"type": "string", "type": "string",
"const": "https://smapi.io/schemas/manifest.json" "const": "https://smapi.io/schemas/i18n.json"
} }
}, },

View File

@ -4,6 +4,10 @@
"title": "SMAPI manifest", "title": "SMAPI manifest",
"description": "Manifest file for a SMAPI mod or content pack", "description": "Manifest file for a SMAPI mod or content pack",
"@documentationUrl": "https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Manifest", "@documentationUrl": "https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Manifest",
"allowComments": true,
"allowTrailingCommas": true,
"type": "object", "type": "object",
"properties": { "properties": {
"Name": { "Name": {

View File

@ -61,7 +61,7 @@ namespace StardewModdingAPI
internal static int? LogScreenId { get; set; } internal static int? LogScreenId { get; set; }
/// <summary>SMAPI's current raw semantic version.</summary> /// <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> /// <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> /// <summary>Get the name of the save folder, if any.</summary>
private static string GetSaveFolderName() private static string GetSaveFolderName()
{ {
// save not available return Constants.GetSaveFolder()?.Name;
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}";
} }
/// <summary>Get the path to the current save folder, if any.</summary> /// <summary>Get the path to the current save folder, if any.</summary>
private static string GetSaveFolderPathIfExists() private static string GetSaveFolderPathIfExists()
{ {
string folderName = Constants.GetSaveFolderName(); DirectoryInfo saveFolder = Constants.GetSaveFolder();
if (folderName == null) 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; return null;
string path = Path.Combine(Constants.SavesPath, folderName); // get basic info
return Directory.Exists(path) string rawSaveName = Game1.GetSaveGameName(set_value: false);
? path ulong saveID = Context.LoadStage == LoadStage.SaveParsed
: null; ? 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;
} }
} }
} }

View File

@ -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();
}
}
}

View File

@ -28,5 +28,8 @@ namespace StardewModdingAPI.Events
/// <summary>Raised after terrain features (like floors and trees) are added or removed in a location.</summary> /// <summary>Raised after terrain features (like floors and trees) are added or removed in a location.</summary>
event EventHandler<TerrainFeatureListChangedEventArgs> TerrainFeatureListChanged; event EventHandler<TerrainFeatureListChangedEventArgs> TerrainFeatureListChanged;
/// <summary>Raised after furniture are added or removed in a location.</summary>
event EventHandler<FurnitureListChangedEventArgs> FurnitureListChanged;
} }
} }

View File

@ -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> /// <summary>Raised after terrain features (like floors and trees) are added or removed in a location.</summary>
public readonly ManagedEvent<TerrainFeatureListChangedEventArgs> TerrainFeatureListChanged; public readonly ManagedEvent<TerrainFeatureListChangedEventArgs> TerrainFeatureListChanged;
/// <summary>Raised after furniture are added or removed in a location.</summary>
public readonly ManagedEvent<FurnitureListChangedEventArgs> FurnitureListChanged;
/**** /****
** Specialized ** Specialized
****/ ****/
@ -238,6 +241,7 @@ namespace StardewModdingAPI.Framework.Events
this.ObjectListChanged = ManageEventOf<ObjectListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.ObjectListChanged)); this.ObjectListChanged = ManageEventOf<ObjectListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.ObjectListChanged));
this.ChestInventoryChanged = ManageEventOf<ChestInventoryChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.ChestInventoryChanged)); this.ChestInventoryChanged = ManageEventOf<ChestInventoryChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.ChestInventoryChanged));
this.TerrainFeatureListChanged = ManageEventOf<TerrainFeatureListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.TerrainFeatureListChanged)); 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.LoadStageChanged = ManageEventOf<LoadStageChangedEventArgs>(nameof(IModEvents.Specialized), nameof(ISpecializedEvents.LoadStageChanged));
this.UnvalidatedUpdateTicking = ManageEventOf<UnvalidatedUpdateTickingEventArgs>(nameof(IModEvents.Specialized), nameof(ISpecializedEvents.UnvalidatedUpdateTicking), isPerformanceCritical: true); this.UnvalidatedUpdateTicking = ManageEventOf<UnvalidatedUpdateTickingEventArgs>(nameof(IModEvents.Specialized), nameof(ISpecializedEvents.UnvalidatedUpdateTicking), isPerformanceCritical: true);

View File

@ -65,6 +65,13 @@ namespace StardewModdingAPI.Framework.Events
remove => this.EventManager.TerrainFeatureListChanged.Remove(value); 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 ** Public methods

View File

@ -195,7 +195,10 @@ namespace StardewModdingAPI.Framework.ModLoading
/// <inheritdoc /> /// <inheritdoc />
public IEnumerable<UpdateKey> GetUpdateKeys(bool validOnly = false) 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); UpdateKey updateKey = UpdateKey.Parse(rawKey);
if (updateKey.LooksValid || !validOnly) if (updateKey.LooksValid || !validOnly)
@ -251,16 +254,19 @@ namespace StardewModdingAPI.Framework.ModLoading
{ {
var ids = new Dictionary<string, bool>(StringComparer.OrdinalIgnoreCase); var ids = new Dictionary<string, bool>(StringComparer.OrdinalIgnoreCase);
// yield dependencies if (this.HasManifest())
if (this.Manifest?.Dependencies != null)
{ {
foreach (var entry in this.Manifest?.Dependencies) // yield dependencies
ids[entry.UniqueID] = entry.IsRequired; foreach (IManifestDependency entry in this.Manifest.Dependencies)
} {
if (!string.IsNullOrWhiteSpace(entry.UniqueID))
ids[entry.UniqueID] = entry.IsRequired;
}
// yield content pack parent // yield content pack parent
if (this.Manifest?.ContentPackFor?.UniqueID != null) if (!string.IsNullOrWhiteSpace(this.Manifest.ContentPackFor?.UniqueID))
ids[this.Manifest.ContentPackFor.UniqueID] = true; ids[this.Manifest.ContentPackFor.UniqueID] = true;
}
return ids; return ids;
} }

View File

@ -82,7 +82,7 @@ namespace StardewModdingAPI.Framework.ModLoading
// get update URLs // get update URLs
List<string> updateUrls = new List<string>(); 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); string url = getUpdateUrl(key);
if (url != null) if (url != null)
@ -173,7 +173,7 @@ namespace StardewModdingAPI.Framework.ModLoading
if (string.IsNullOrWhiteSpace(mod.Manifest.Name)) if (string.IsNullOrWhiteSpace(mod.Manifest.Name))
missingFields.Add(nameof(IManifest.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)); missingFields.Add(nameof(IManifest.Version));
if (string.IsNullOrWhiteSpace(mod.Manifest.UniqueID)) if (string.IsNullOrWhiteSpace(mod.Manifest.UniqueID))
missingFields.Add(nameof(IManifest.UniqueID)); missingFields.Add(nameof(IManifest.UniqueID));
@ -188,6 +188,28 @@ namespace StardewModdingAPI.Framework.ModLoading
// validate ID format // validate ID format
if (!PathUtilities.IsSlug(mod.Manifest.UniqueID)) 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)."); 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 // validate IDs are unique

View File

@ -22,7 +22,7 @@ namespace StardewModdingAPI.Framework.Models
[nameof(VerboseLogging)] = false, [nameof(VerboseLogging)] = false,
[nameof(LogNetworkTraffic)] = false, [nameof(LogNetworkTraffic)] = false,
[nameof(RewriteMods)] = true, [nameof(RewriteMods)] = true,
[nameof(AggressiveMemoryOptimizations)] = true [nameof(AggressiveMemoryOptimizations)] = false
}; };
/// <summary>The default values for <see cref="SuppressUpdateChecks"/>, to log changes if different.</summary> /// <summary>The default values for <see cref="SuppressUpdateChecks"/>, to log changes if different.</summary>

View File

@ -11,6 +11,9 @@ using System.Security;
using System.Text; using System.Text;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
#if SMAPI_FOR_WINDOWS
using Microsoft.Win32;
#endif
using Microsoft.Xna.Framework; using Microsoft.Xna.Framework;
#if SMAPI_FOR_XNA #if SMAPI_FOR_XNA
using System.Windows.Forms; using System.Windows.Forms;
@ -294,7 +297,14 @@ namespace StardewModdingAPI.Framework
} }
finally 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(); mods = resolver.ProcessDependencies(mods, modDatabase).ToArray();
this.LoadMods(mods, this.Toolkit.JsonHelper, this.ContentCore, modDatabase); this.LoadMods(mods, this.Toolkit.JsonHelper, this.ContentCore, modDatabase);
// check for software likely to cause issues
this.CheckForSoftwareConflicts();
// check for updates // check for updates
this.CheckForUpdatesAsync(mods); this.CheckForUpdatesAsync(mods);
} }
@ -914,6 +927,10 @@ namespace StardewModdingAPI.Framework
// terrain features changed // terrain features changed
if (locState.TerrainFeatures.IsChanged) if (locState.TerrainFeatures.IsChanged)
events.TerrainFeatureListChanged.Raise(new TerrainFeatureListChangedEventArgs(location, locState.TerrainFeatures.Added, locState.TerrainFeatures.Removed)); 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); 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> /// <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> /// <param name="mods">The mods to include in the update check (if eligible).</param>
private void CheckForUpdatesAsync(IModMetadata[] mods) private void CheckForUpdatesAsync(IModMetadata[] mods)
@ -1593,19 +1659,16 @@ namespace StardewModdingAPI.Framework
// validate dependencies // validate dependencies
// Although dependencies are validated before mods are loaded, a dependency may have failed to load. // 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))
string dependencyName = mods ?.DisplayName ?? dependency.UniqueID;
.FirstOrDefault(otherMod => otherMod.HasID(dependency.UniqueID)) errorReasonPhrase = $"it needs the '{dependencyName}' mod, which couldn't be loaded.";
?.DisplayName ?? dependency.UniqueID; failReason = ModFailReason.MissingDependencies;
errorReasonPhrase = $"it needs the '{dependencyName}' mod, which couldn't be loaded."; return false;
failReason = ModFailReason.MissingDependencies;
return false;
}
} }
} }

View File

@ -48,6 +48,9 @@ namespace StardewModdingAPI.Framework.StateTracking
/// <summary>Tracks added or removed terrain features.</summary> /// <summary>Tracks added or removed terrain features.</summary>
public IDictionaryWatcher<Vector2, TerrainFeature> TerrainFeaturesWatcher { get; } 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> /// <summary>Tracks items added or removed to chests.</summary>
public IDictionary<Vector2, ChestTracker> ChestWatchers { get; } = new Dictionary<Vector2, ChestTracker>(); 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.NpcsWatcher = WatcherFactory.ForNetCollection(location.characters);
this.ObjectsWatcher = WatcherFactory.ForNetDictionary(location.netObjects); this.ObjectsWatcher = WatcherFactory.ForNetDictionary(location.netObjects);
this.TerrainFeaturesWatcher = WatcherFactory.ForNetDictionary(location.terrainFeatures); this.TerrainFeaturesWatcher = WatcherFactory.ForNetDictionary(location.terrainFeatures);
this.FurnitureWatcher = WatcherFactory.ForNetCollection(location.furniture);
this.Watchers.AddRange(new IWatcher[] this.Watchers.AddRange(new IWatcher[]
{ {
@ -76,7 +80,8 @@ namespace StardewModdingAPI.Framework.StateTracking
this.LargeTerrainFeaturesWatcher, this.LargeTerrainFeaturesWatcher,
this.NpcsWatcher, this.NpcsWatcher,
this.ObjectsWatcher, this.ObjectsWatcher,
this.TerrainFeaturesWatcher this.TerrainFeaturesWatcher,
this.FurnitureWatcher
}); });
this.UpdateChestWatcherList(added: location.Objects.Pairs, removed: new KeyValuePair<Vector2, SObject>[0]); this.UpdateChestWatcherList(added: location.Objects.Pairs, removed: new KeyValuePair<Vector2, SObject>[0]);

View File

@ -34,6 +34,9 @@ namespace StardewModdingAPI.Framework.StateTracking.Snapshots
/// <summary>Tracks added or removed terrain features.</summary> /// <summary>Tracks added or removed terrain features.</summary>
public SnapshotListDiff<KeyValuePair<Vector2, TerrainFeature>> TerrainFeatures { get; } = new SnapshotListDiff<KeyValuePair<Vector2, TerrainFeature>>(); 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> /// <summary>Tracks changed chest inventories.</summary>
public IDictionary<Chest, SnapshotItemListDiff> ChestItems { get; } = new Dictionary<Chest, SnapshotItemListDiff>(); 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.Npcs.Update(watcher.NpcsWatcher);
this.Objects.Update(watcher.ObjectsWatcher); this.Objects.Update(watcher.ObjectsWatcher);
this.TerrainFeatures.Update(watcher.TerrainFeaturesWatcher); this.TerrainFeatures.Update(watcher.TerrainFeaturesWatcher);
this.Furniture.Update(watcher.FurnitureWatcher);
// chest inventories // chest inventories
this.ChestItems.Clear(); this.ChestItems.Clear();

View File

@ -233,6 +233,16 @@ namespace StardewModdingAPI.Metadata
return true; 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 ** Content\Characters\Farmer
****/ ****/
@ -613,7 +623,7 @@ namespace StardewModdingAPI.Metadata
return this.ReloadFarmAnimalSprites(content, key); return this.ReloadFarmAnimalSprites(content, key);
if (this.IsInFolder(key, "Buildings")) if (this.IsInFolder(key, "Buildings"))
return this.ReloadBuildings(content, key); return this.ReloadBuildings(key);
if (this.KeyStartsWith(key, "LooseSprites\\Fence")) if (this.KeyStartsWith(key, "LooseSprites\\Fence"))
return this.ReloadFenceTextures(key); return this.ReloadFenceTextures(key);
@ -717,28 +727,39 @@ namespace StardewModdingAPI.Metadata
} }
/// <summary>Reload building textures.</summary> /// <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> /// <param name="key">The asset key to reload.</param>
/// <returns>Returns whether any textures were reloaded.</returns> /// <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); string type = Path.GetFileName(key);
if (isPaintMask)
type = type.Substring(0, type.Length - paintMaskSuffix.Length);
// get buildings
Building[] buildings = this.GetLocations(buildingInteriors: false) Building[] buildings = this.GetLocations(buildingInteriors: false)
.OfType<BuildableGameLocation>() .OfType<BuildableGameLocation>()
.SelectMany(p => p.buildings) .SelectMany(p => p.buildings)
.Where(p => p.buildingType.Value == type) .Where(p => p.buildingType.Value == type)
.ToArray(); .ToArray();
// reload buildings // remove from paint mask cache
bool removedFromCache = this.RemoveFromPaintMaskCache(key);
// reload textures
if (buildings.Any()) if (buildings.Any())
{ {
Lazy<Texture2D> texture = new Lazy<Texture2D>(() => content.Load<Texture2D>(key));
foreach (Building building in buildings) foreach (Building building in buildings)
building.texture = texture; building.resetTexture();
return true; return true;
} }
return false;
return removedFromCache;
} }
/// <summary>Reload map seat textures.</summary> /// <summary>Reload map seat textures.</summary>
@ -1295,5 +1316,18 @@ namespace StardewModdingAPI.Metadata
// else just (re)load it from the main content manager // else just (re)load it from the main content manager
return this.MainContentManager.Load<Texture2D>(key); 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);
}
} }
} }