using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Content; using StardewModdingAPI.Mods.ConsoleCommands.Framework.ItemData; using StardewValley; using StardewValley.GameData.FishPond; using StardewValley.Menus; using StardewValley.Objects; using StardewValley.Tools; using SObject = StardewValley.Object; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework { /// Provides methods for searching and constructing items. internal class ItemRepository { /********* ** Fields *********/ /// The custom ID offset for items don't have a unique ID in the game. private readonly int CustomIDOffset = 1000; /********* ** Public methods *********/ /// Get all spawnable items. /// The item types to fetch (or null for any type). /// Whether to include flavored variants like "Sunflower Honey". [SuppressMessage("ReSharper", "AccessToModifiedClosure", Justification = "TryCreate invokes the lambda immediately.")] public IEnumerable GetAll(ItemType[]? itemTypes = null, bool includeVariants = true) { // // // Be careful about closure variable capture here! // // SearchableItem stores the Func to create new instances later. Loop variables passed into the // function will be captured, so every func in the loop will use the value from the last iteration. Use the // TryCreate(type, id, entity => item) form to avoid the issue, or create a local variable to pass in. // // IEnumerable GetAllRaw() { HashSet? types = itemTypes?.Any() == true ? new HashSet(itemTypes) : null; bool ShouldGet(ItemType type) => types == null || types.Contains(type); // get tools if (ShouldGet(ItemType.Tool)) { for (int q = Tool.stone; q <= Tool.iridium; q++) { int quality = q; yield return this.TryCreate(ItemType.Tool, ToolFactory.axe, _ => ToolFactory.getToolFromDescription(ToolFactory.axe, quality)); yield return this.TryCreate(ItemType.Tool, ToolFactory.hoe, _ => ToolFactory.getToolFromDescription(ToolFactory.hoe, quality)); yield return this.TryCreate(ItemType.Tool, ToolFactory.pickAxe, _ => ToolFactory.getToolFromDescription(ToolFactory.pickAxe, quality)); yield return this.TryCreate(ItemType.Tool, ToolFactory.wateringCan, _ => ToolFactory.getToolFromDescription(ToolFactory.wateringCan, quality)); if (quality != Tool.iridium) yield return this.TryCreate(ItemType.Tool, ToolFactory.fishingRod, _ => ToolFactory.getToolFromDescription(ToolFactory.fishingRod, quality)); } yield return this.TryCreate(ItemType.Tool, this.CustomIDOffset, _ => new MilkPail()); // these don't have any sort of ID, so we'll just assign some arbitrary ones yield return this.TryCreate(ItemType.Tool, this.CustomIDOffset + 1, _ => new Shears()); yield return this.TryCreate(ItemType.Tool, this.CustomIDOffset + 2, _ => new Pan()); yield return this.TryCreate(ItemType.Tool, this.CustomIDOffset + 3, _ => new Wand()); } // clothing if (ShouldGet(ItemType.Clothing)) { foreach (int id in this.GetShirtIds()) yield return this.TryCreate(ItemType.Clothing, id, p => new Clothing(p.ID)); } // wallpapers 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 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 if (ShouldGet(ItemType.Boots)) { foreach (int id in this.TryLoad("Data\\Boots").Keys) yield return this.TryCreate(ItemType.Boots, id, p => new Boots(p.ID)); } if (ShouldGet(ItemType.Hat)) { foreach (int id in this.TryLoad("Data\\hats").Keys) yield return this.TryCreate(ItemType.Hat, id, p => new Hat(p.ID)); } // weapons if (ShouldGet(ItemType.Weapon)) { foreach (int id in this.TryLoad("Data\\weapons").Keys) { yield return this.TryCreate(ItemType.Weapon, id, p => p.ID is >= 32 and <= 34 ? new Slingshot(p.ID) : new MeleeWeapon(p.ID) ); } } // furniture if (ShouldGet(ItemType.Furniture)) { foreach (int id in this.TryLoad("Data\\Furniture").Keys) yield return this.TryCreate(ItemType.Furniture, id, p => Furniture.GetFurnitureInstance(p.ID)); } // craftables 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 if (ShouldGet(ItemType.Object) || ShouldGet(ItemType.Ring)) { foreach (int id in Game1.objectInformation.Keys) { string[]? fields = Game1.objectInformation[id]?.Split('/'); // ring if (id != 801 && fields?.Length >= 4 && fields[3] == "Ring") // 801 = wedding ring, which isn't an equippable ring { if (ShouldGet(ItemType.Ring)) yield return this.TryCreate(ItemType.Ring, id, p => new Ring(p.ID)); } // journal scrap else if (id == 842) { if (ShouldGet(ItemType.Object)) { foreach (SearchableItem? journalScrap in this.GetSecretNotes(isJournalScrap: true)) yield return journalScrap; } } // secret notes else if (id == 79) { if (ShouldGet(ItemType.Object)) { foreach (SearchableItem? secretNote in this.GetSecretNotes(isJournalScrap: false)) yield return secretNote; } } // object else if (ShouldGet(ItemType.Object)) { // 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) { foreach (SearchableItem? variant in this.GetFlavoredObjectVariants(item)) yield return variant; } } } } } return ( from item in GetAllRaw() where item != null select item ); } /********* ** Private methods *********/ /// Get the individual secret note or journal scrap items. /// Whether to get journal scraps. /// Derived from . private IEnumerable GetSecretNotes(bool isJournalScrap) { // get base item ID int baseId = isJournalScrap ? 842 : 79; // get secret note IDs var ids = this .TryLoad("Data\\SecretNotes") .Keys .Where(isJournalScrap ? id => (id >= GameLocation.JOURNAL_INDEX) : id => (id < GameLocation.JOURNAL_INDEX) ) .Select(isJournalScrap ? id => (id - GameLocation.JOURNAL_INDEX) : id => id ); // build items foreach (int id in ids) { int fakeId = this.CustomIDOffset * 8 + id; if (isJournalScrap) fakeId += GameLocation.JOURNAL_INDEX; yield return this.TryCreate(ItemType.Object, fakeId, _ => { SObject note = new(baseId, 1); note.Name = $"{note.Name} #{id}"; return note; }); } } /// Get flavored variants of a base item (like Blueberry Wine for Blueberry), if any. /// A sample of the base item. private IEnumerable GetFlavoredObjectVariants(SObject item) { int id = item.ParentSheetIndex; switch (item.Category) { // fruit products case SObject.FruitsCategory: // wine yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 2 + id, _ => new SObject(348, 1) { Name = $"{item.Name} Wine", Price = item.Price * 3, preserve = { SObject.PreserveType.Wine }, preservedParentSheetIndex = { id } }); // jelly yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 3 + id, _ => new SObject(344, 1) { Name = $"{item.Name} Jelly", Price = 50 + item.Price * 2, preserve = { SObject.PreserveType.Jelly }, preservedParentSheetIndex = { id } }); break; // vegetable products case SObject.VegetableCategory: // juice yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 4 + id, _ => new SObject(350, 1) { Name = $"{item.Name} Juice", Price = (int)(item.Price * 2.25d), preserve = { SObject.PreserveType.Juice }, preservedParentSheetIndex = { id } }); // pickled yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 5 + id, _ => new SObject(342, 1) { Name = $"Pickled {item.Name}", Price = 50 + item.Price * 2, preserve = { SObject.PreserveType.Pickle }, preservedParentSheetIndex = { id } }); break; // flower honey case SObject.flowersCategory: yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 5 + id, _ => { SObject honey = new(Vector2.Zero, 340, $"{item.Name} Honey", false, true, false, false) { Name = $"{item.Name} Honey", preservedParentSheetIndex = { id } }; honey.Price += item.Price * 2; return honey; }); break; // roe and aged roe (derived from FishPond.GetFishProduce) case SObject.sellAtFishShopCategory when id == 812: { this.GetRoeContextTagLookups(out HashSet simpleTags, out List> complexTags); foreach (var pair in Game1.objectInformation) { // get input SObject? input = this.TryCreate(ItemType.Object, pair.Key, p => new SObject(p.ID, 1))?.Item as SObject; if (input == null) continue; HashSet inputTags = input.GetContextTags(); if (!inputTags.Any()) 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 + id, _ => { 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 + id, _ => 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; } } /// Get optimized lookups to match items which produce roe in a fish pond. /// A lookup of simple singular tags which match a roe-producing fish. /// A list of tag sets which match roe-producing fish. private void GetRoeContextTagLookups(out HashSet simpleTags, out List> complexTags) { simpleTags = new HashSet(); complexTags = new List>(); foreach (FishPondData data in Game1.content.Load>("Data\\FishPondData")) { if (data.ProducedItems.All(p => p.ItemID != 812)) continue; // doesn't produce roe if (data.RequiredTags.Count == 1 && !data.RequiredTags[0].StartsWith("!")) simpleTags.Add(data.RequiredTags[0]); else complexTags.Add(data.RequiredTags); } } /// Try to load a data file, and return empty data if it's invalid. /// The asset key type. /// The asset value type. /// The data asset name. private Dictionary TryLoad(string assetName) where TKey : notnull { try { return Game1.content.Load>(assetName); } catch (ContentLoadException) { // generally due to a player incorrectly replacing a data file with an XNB mod return new Dictionary(); } } /// Create a searchable item if valid. /// The item type. /// The unique ID (if different from the item's parent sheet index). /// Create an item instance. private SearchableItem? TryCreate(ItemType type, int id, Func createItem) { try { var item = new SearchableItem(type, id, createItem); item.Item.getDescription(); // force-load item data, so it crashes here if it's invalid return item; } catch { return null; // if some item data is invalid, just don't include it } } /// Get the color to use a given fish's roe. /// The fish whose roe to color. /// Derived from . private Color GetRoeColor(SObject fish) { return fish.ParentSheetIndex == 698 // sturgeon ? new Color(61, 55, 42) : (TailoringMenu.GetDyeColor(fish) ?? Color.Orange); } /// Get valid shirt IDs. /// /// Shirts have a possible range of 1000–1999, but not all of those IDs are valid. There are two sets of IDs: /// /// /// /// Shirts which exist in . /// /// /// Shirts with a dynamic ID and no entry in . These automatically /// use the generic shirt entry with ID -1 and are mapped to a calculated position in the /// Characters/Farmer/shirts spritesheet. There's no constant we can use, but some known valid /// ranges are 1000–1111 (used in for the customization screen and /// 1000–1127 (used in and ). /// Based on the spritesheet, the max valid ID is 1299. /// /// /// private IEnumerable GetShirtIds() { // defined shirt items foreach (int id in Game1.clothingInformation.Keys) { if (id < 0) continue; // placeholder data for character customization clothing below yield return id; } // dynamic shirts HashSet clothingIds = new HashSet(Game1.clothingInformation.Keys); for (int id = 1000; id <= 1299; id++) { if (!clothingIds.Contains(id)) yield return id; } } } }