/// <summary>Check each saved object with an expiration setting, respawning them if they were removed after being saved (e.g. by the weekly forage removal process).</summary> /// <param name="save">The save data to the checked.</param> public static void ReplaceProtectedSpawns(InternalSaveData save) { int missing = 0; //# of objects missing int blocked = 0; //# of objects that could not respawn due to blocked locations int respawned = 0; //# of objects respawned int unloaded = 0; //# of objects skipped due to missing (unloaded) or invalid map names int uninstalled = 0; //# of objects skipped due to missing object data, generally caused by removed mods foreach (SavedObject saved in save.SavedObjects) { if (saved.DaysUntilExpire == null) //if the object's expiration setting is null { continue; //skip to the next object } GameLocation location = Game1.getLocationFromName(saved.MapName); //get the object's location if (location == null) //if the map wasn't found { unloaded++; //increment unloaded tracker continue; //skip to the next object } if (saved.Type == SavedObject.ObjectType.Monster) //if this is a monster { missing++; //increment missing tracker (note: monsters should always be removed overnight) //this mod should remove all of its monsters overnight, so respawn this monster without checking for its existence int?newID = SpawnMonster(saved.MonType, location, saved.Tile, "[No Area ID: Respawning previously saved monster.]"); //respawn the monster and get its new ID (null if spawn failed) if (newID.HasValue) //if a monster ID was generated { saved.ID = newID.Value; //update this monster's saved ID respawned++; //increment respawn tracker } else //if spawn failed (presumably due to obstructions) { blocked++; //increment obstruction tracker } } else if (saved.Type == SavedObject.ObjectType.LargeObject) //if this is a large object { IEnumerable <TerrainFeature> resourceClumps = null; //a list of large objects at this location if (location is Farm farm) { resourceClumps = farm.resourceClumps.ToList(); //use the farm's clump list } else if (location is MineShaft mine) { resourceClumps = mine.resourceClumps.ToList(); //use the mine's clump list } else { resourceClumps = location.largeTerrainFeatures.OfType <LargeResourceClump>(); //use this location's large resource clump list } bool stillExists = false; //does this large object still exist? foreach (TerrainFeature clump in resourceClumps) //for each of this location's large objects { if (clump is ResourceClump smallClump) { if (smallClump.tile.X == saved.Tile.X && smallClump.tile.Y == saved.Tile.Y && smallClump.parentSheetIndex.Value == saved.ID) //if this clump's location & ID match the saved object { stillExists = true; break; //stop searching the clump list } } else if (clump is LargeResourceClump largeClump) { if (largeClump.Clump.Value.tile.X == saved.Tile.X && largeClump.Clump.Value.tile.Y == saved.Tile.Y && largeClump.Clump.Value.parentSheetIndex.Value == saved.ID) //if this clump's location & ID match the saved object { stillExists = true; break; //stop searching the clump list } } } if (!stillExists) //if the object no longer exists { missing++; //increment missing tracker if (IsTileValid(location, saved.Tile, saved.Size, "High")) //if the object's tile is valid for large object placement (defaulting to "high" strictness) { SpawnLargeObject(saved.ID.Value, location, saved.Tile); //respawn the object respawned++; //increment respawn tracker } else //if the object's tile is invalid { blocked++; //increment obstruction tracker } } } else if (saved.Type == SavedObject.ObjectType.Item) //if this is a forage item { missing++; //increment missing tracker (note: items should always be removed overnight) //this mod should remove all of its forage items overnight, so respawn this item without checking for its existence if (IsTileValid(location, saved.Tile, new Point(1, 1), "Medium") && !location.terrainFeatures.ContainsKey(saved.Tile)) //if the item's tile is clear enough to respawn { //update this item's ID, in case it changed due to other mods string[] categoryAndName = saved.Name.Split(':'); int? newID = GetItemID(categoryAndName[0], categoryAndName[1]); if (newID.HasValue) //if a new ID was successfully generated { respawned++; //increment respawn tracker saved.ID = newID; //save the new ID SpawnForage(saved, location, saved.Tile); //respawn the item } else //if a new ID could not be generated { uninstalled++; //increment uninstalled mod tracker Monitor.LogOnce($"Couldn't find a valid ID for a previously saved forage item. Item name: {saved.Name}", LogLevel.Trace); } } else //if this object's tile is obstructed { blocked++; //increment obstruction tracker } } else if (saved.Type == SavedObject.ObjectType.Container) //if this is a container { missing++; //increment missing tracker (note: chests should always be removed overnight) //this mod should remove all of its containers overnight, so respawn this container without checking for its existence if (IsTileValid(location, saved.Tile, new Point(1, 1), "Medium")) //if the container's tile is clear enough to respawn { respawned++; //increment respawn tracker SpawnForage(saved, location, saved.Tile); //respawn the container } else //if this object's tile is obstructed { blocked++; //increment obstruction tracker } } else if (saved.Type == SavedObject.ObjectType.DGA) //if this is a DGA item { StardewValley.Object realObject = location.getObjectAtTile((int)saved.Tile.X, (int)saved.Tile.Y); //get the object at the saved location (if any) Furniture realFurniture = location.GetFurnitureAt(saved.Tile); //get the furniture at the saved location (if any) bool featureExists = location.terrainFeatures.TryGetValue(saved.Tile, out TerrainFeature realFeature); //try to get a terrain feature at this location if (DGAItemAPI == null) //if DGA isn't available { uninstalled++; //increment uninstalled mod tracker Monitor.LogOnce($"The interface for Dynamic Game Assets (DGA) is unavailable, so a DGA item couldn't be respawned from save data.", LogLevel.Trace); } else if ((realObject == null || DGAItemAPI.GetDGAItemId(realObject) != saved.Name) && //if a matching DGA object is NOT here (realFurniture == null || DGAItemAPI.GetDGAItemId(realFurniture) != saved.Name) && //AND a matching DGA furniture is NOT here (!featureExists || realFeature is not PlacedItem placed || placed.Item == null || DGAItemAPI.GetDGAItemId(placed.Item) != saved.Name)) //AND a matching DGA item is NOT here { missing++; //increment missing object tracker if (IsTileValid(location, saved.Tile, new Point(1, 1), "Medium")) //if the item's tile is clear enough to respawn { respawned++; //increment respawn tracker SpawnForage(saved, location, saved.Tile); //respawn the DGA item } else //if the object's tile is obstructed { blocked++; //increment obstruction tracker } } } else //if this is forage or ore { StardewValley.Object realObject = location.getObjectAtTile((int)saved.Tile.X, (int)saved.Tile.Y); //get the object at the saved location if (realObject == null) //if the object no longer exists { missing++; //increment missing object tracker if (IsTileValid(location, saved.Tile, new Point(1, 1), "Medium")) //if the object's tile is clear enough to respawn { if (saved.Type == SavedObject.ObjectType.Object) //if this is a forage object { if (saved.Name != null) //if this forage was originally assigned a name { //update this forage's ID, in case it changed due to other mods if (saved.Name.Contains(':')) //if this is "category:name" { string[] categoryAndName = saved.Name.Split(':'); saved.ID = GetItemID(categoryAndName[0], categoryAndName[1]); } else //if this is just an object name { saved.ID = GetItemID("object", saved.Name); } } if (saved.ID.HasValue) //if a valid ID was found for this object { respawned++; //increment respawn tracker SpawnForage(saved, location, saved.Tile); //respawn it } else { uninstalled++; //increment uninstalled mod tracker Monitor.LogOnce($"Couldn't find a valid ID for a previously saved forage object. Object name: {saved.Name}", LogLevel.Trace); } } else //if this is ore { respawned++; //increment respawn tracker SpawnOre(saved.Name, location, saved.Tile); //respawn it } } else //if the object's tile is occupied { blocked++; //increment obstruction tracker } } } } Monitor.Log($"Missing objects: {missing}. Respawned: {respawned}. Not respawned due to obstructions: {blocked}. Skipped due to missing maps: {unloaded}. Skipped due to missing item types: {uninstalled}.", LogLevel.Trace); }
/// <summary>Check each saved object's expiration data, updating counters & removing missing/expired objects from the data and custom classes from the game world.</summary> /// <param name="save">The save data to the checked.</param> /// <param name="endOfDay">If false, expiration dates will be ignored. Used to temporarily remove custom classes during the day.</param> public static void ProcessObjectExpiration(InternalSaveData save, bool endOfDay = true) { List <SavedObject> objectsToRemove = new List <SavedObject>(); //objects to remove from saved data after processing (note: do not remove them while looping through them) foreach (SavedObject saved in save.SavedObjects) //for each saved object & expiration countdown { if (saved.DaysUntilExpire == null && saved.Type != SavedObject.ObjectType.Monster) //if the object's expiration setting is null & it's not a monster { Monitor.VerboseLog($"Removing object data saved with a null expiration setting. Type: {saved.Type.ToString()}. ID: {saved.ID}. Location: {saved.MapName}."); objectsToRemove.Add(saved); //mark this for removal from save continue; //skip to the next object } //if this saved object has an expiration setting, AND the target map is a known temporary location if (saved.DaysUntilExpire.HasValue && (saved.MapName.StartsWith("UndergroundMine", StringComparison.OrdinalIgnoreCase) || //mine level saved.MapName.StartsWith("VolcanoDungeon", StringComparison.OrdinalIgnoreCase))) //volcano level { saved.DaysUntilExpire = 1; //force this object to expire below } GameLocation location = Game1.getLocationFromName(saved.MapName); //get the saved object's location if (location == null) //if this isn't a valid map { Monitor.VerboseLog($"Removing object data saved for a missing location. Type: {saved.Type.ToString()}. ID: {saved.ID}. Location: {saved.MapName}."); objectsToRemove.Add(saved); //mark this for removal from save continue; //skip to the next object } if (saved.Type == SavedObject.ObjectType.Monster) //if this is a monster { bool stillExists = false; //does this monster still exist? for (int x = location.characters.Count - 1; x >= 0; x--) //for each character at this location (looping backward for removal purposes) { if (location.characters[x] is Monster monster && monster.id == saved.ID) //if this is a monster with an ID that matches the saved ID { stillExists = true; if (endOfDay) //if expirations should be processed { if (saved.DaysUntilExpire == 1 || saved.DaysUntilExpire == null) //if this should expire tonight (including monsters generated without expiration settings) { Monitor.VerboseLog($"Removing expired object. Type: {saved.Type.ToString()}. ID: {saved.ID}. Location: {saved.MapName}."); objectsToRemove.Add(saved); //mark this for removal from save } else if (saved.DaysUntilExpire > 1) //if the object should expire, but not tonight { saved.DaysUntilExpire--; //decrease counter by 1 } } if (saved.MonType != null && saved.MonType.Settings.ContainsKey("PersistentHP") && (bool)saved.MonType.Settings["PersistentHP"]) //if the PersistentHP setting is enabled for this monster { saved.MonType.Settings["CurrentHP"] = monster.Health; //save this monster's current HP } location.characters.RemoveAt(x); //remove this monster from the location, regardless of expiration break; //stop searching the character list } } if (!stillExists) //if this monster no longer exists { Monitor.VerboseLog($"Removing missing object. Type: {saved.Type.ToString()}. ID: {saved.ID}. Location: {saved.MapName}."); objectsToRemove.Add(saved); //mark this for removal from save } } else if (saved.Type == SavedObject.ObjectType.ResourceClump) //if this is a resource clump { IEnumerable <TerrainFeature> resourceClumps = null; //a list of resource clumps at this location if (location is Farm farm) { resourceClumps = farm.resourceClumps.ToList(); //use the farm's clump list } else if (location is MineShaft mine) { resourceClumps = mine.resourceClumps.ToList(); //use the mine's clump list } else { resourceClumps = location.largeTerrainFeatures.OfType <LargeResourceClump>(); //use this location's large resource clump list } TerrainFeature existingObject = null; //the in-game object, if it currently exists foreach (TerrainFeature clump in resourceClumps) //for each of this location's large objects { if (clump is ResourceClump smallClump) { if (smallClump.tile.X == saved.Tile.X && smallClump.tile.Y == saved.Tile.Y && smallClump.parentSheetIndex.Value == saved.ID) //if this clump's location & ID match the saved object { existingObject = smallClump; break; //stop searching the clump list } } else if (clump is LargeResourceClump largeClump) { if (largeClump.Clump.Value.tile.X == saved.Tile.X && largeClump.Clump.Value.tile.Y == saved.Tile.Y && largeClump.Clump.Value.parentSheetIndex.Value == saved.ID) //if this clump's location & ID match the saved object { existingObject = largeClump; break; //stop searching the clump list } } } if (existingObject != null) //if the object still exists { if (endOfDay) //if expirations should be processed { if (saved.DaysUntilExpire == 1) //if the object should expire tonight { Monitor.VerboseLog($"Removing expired object. Type: {saved.Type.ToString()}. ID: {saved.ID}. Location: {saved.Tile.X},{saved.Tile.Y} ({saved.MapName})."); if (existingObject is ResourceClump clump) //if this is NOT a custom class that always needs removal { if (location is Farm farmLoc) { farmLoc.resourceClumps.Remove(clump); //remove this object from the farm's resource clumps list } else if (location is MineShaft mineLoc) { mineLoc.resourceClumps.Remove(clump); //remove this object from the mine's resource clumps list } } objectsToRemove.Add(saved); //mark object for removal from save } else if (saved.DaysUntilExpire > 1) //if the object should expire, but not tonight { saved.DaysUntilExpire--; //decrease counter by 1 } } if (existingObject is LargeResourceClump largeClump) //if this is a custom class that always needs removal { location.largeTerrainFeatures.Remove(largeClump); //remove this object from the large terrain features list (NOTE: this must be done even for unexpired LargeResourceClumps to avoid SDV save errors) } } else //if the object no longer exists { Monitor.VerboseLog($"Removing missing object. Type: {saved.Type.ToString()}. ID: {saved.ID}. Location: {saved.MapName}."); objectsToRemove.Add(saved); //mark object for removal from save } } else if (saved.Type == SavedObject.ObjectType.Item) //if this is a forage item, i.e. "debris" containing an item { bool stillExists = false; //does this item still exist? //if a PlacedItem terrain feature exists at the saved tile & contains an item with a matching name if (location.terrainFeatures.ContainsKey(saved.Tile) && location.terrainFeatures[saved.Tile] is PlacedItem placedItem && placedItem.Item?.ParentSheetIndex == saved.ID.Value) { stillExists = true; location.terrainFeatures.Remove(saved.Tile); //remove this placed item, regardless of expiration if (endOfDay) //if expirations should be processed { if (saved.DaysUntilExpire == 1 || saved.DaysUntilExpire == null) //if this should expire tonight { Monitor.VerboseLog($"Removing expired object. Type: {saved.Type.ToString()}. Name: {placedItem.Item?.Name}. Location: {saved.MapName}."); objectsToRemove.Add(saved); //mark this for removal from save } else if (saved.DaysUntilExpire > 1) //if this should expire, but not tonight { saved.DaysUntilExpire--; //decrease counter by 1 } } } if (!stillExists) //if this item no longer exists { Monitor.VerboseLog($"Removing missing object. Type: {saved.Type.ToString()}. ID: {saved.ID}. Location: {saved.MapName}."); objectsToRemove.Add(saved); //mark this for removal from save } } else if (saved.Type == SavedObject.ObjectType.Container) //if this is a container { if (location.Objects.TryGetValue(saved.Tile, out StardewValley.Object realObject)) //if an object exists in the saved location { bool sameContainerCategory = false; switch (saved.ConfigItem?.Category.ToLower()) //compare the saved object's category to this object's class { case "barrel": case "barrels": case "breakable": case "breakables": case "crate": case "crates": if (realObject is BreakableContainerFTM) { sameContainerCategory = true; } break; case "buried": case "burieditem": case "burieditems": case "buried item": case "buried items": if (realObject is BuriedItems) { sameContainerCategory = true; } break; case "chest": case "chests": if (realObject is Chest) { sameContainerCategory = true; } break; } if (sameContainerCategory) //if the real object matches the saved object's category { if (realObject is Chest chest) //if this is a chest { while (chest.items.Count < saved.ConfigItem?.Contents.Count) //while this chest has less items than the saved object's "contents" { saved.ConfigItem.Contents.RemoveAt(0); //remove a missing item from the ConfigItem's contents (note: chests output the item at index 0 when used) } } realObject.CanBeGrabbed = true; //workaround for certain objects being ignored by the removeObject method location.removeObject(saved.Tile, false); //remove this container from the location, regardless of expiration if (endOfDay) //if expirations should be processed { if (saved.DaysUntilExpire == 1) //if the object should expire tonight { Monitor.VerboseLog($"Removing expired container. Type: {saved.Type.ToString()}. Category: {saved.ConfigItem?.Category}. Location: {saved.Tile.X},{saved.Tile.Y} ({saved.MapName})."); objectsToRemove.Add(saved); //mark object for removal from save } else if (saved.DaysUntilExpire > 1) //if the object should expire, but not tonight { saved.DaysUntilExpire--; //decrease counter by 1 } } } else //if the real object does NOT match the saved object's category { Monitor.VerboseLog($"Removing missing object. Type: {saved.Type.ToString()}. Category: {saved.ConfigItem?.Category}. Location: {saved.MapName}."); objectsToRemove.Add(saved); //mark object for removal from save } } else //if the object no longer exists { Monitor.VerboseLog($"Removing missing object. Type: {saved.Type.ToString()}. Category: {saved.ConfigItem?.Category}. Location: {saved.MapName}."); objectsToRemove.Add(saved); //mark object for removal from save } } else if (saved.Type == SavedObject.ObjectType.DGA) //if this is a DGA item { if //if a matching PlacedItem exists here ( location.terrainFeatures.ContainsKey(saved.Tile) && //if this tile has a features location.terrainFeatures[saved.Tile] is PlacedItem placedItem && //and it's a placed item placedItem.Item != null && //and it isn't empty DGAItemAPI?.GetDGAItemId(placedItem.Item) == saved.Name //and the contained item matches the saved name (according to DGA's API) ) { location.terrainFeatures.Remove(saved.Tile); //remove this placed item, regardless of expiration if (endOfDay) //if expirations should be processed { if (saved.DaysUntilExpire == 1 || saved.DaysUntilExpire == null) //if this should expire tonight { Monitor.VerboseLog($"Removing expired object. Type: DGA item. Name: {saved.Name}. Location: {saved.Tile.X},{saved.Tile.Y} ({saved.MapName})."); objectsToRemove.Add(saved); //mark this for removal from save } else if (saved.DaysUntilExpire > 1) //if this should expire, but not tonight { saved.DaysUntilExpire--; //decrease counter by 1 } } } else if (location.GetFurnitureAt(saved.Tile) is Furniture realFurniture && DGAItemAPI?.GetDGAItemId(realFurniture) == saved.Name) //if matching furniture exists here { location.furniture.Remove(realFurniture); //remove this furniture, regardless of expiration if (endOfDay) //if expirations should be processed { if (saved.DaysUntilExpire == 1 || saved.DaysUntilExpire == null) //if this should expire tonight { Monitor.VerboseLog($"Removing expired object. Type: DGA furniture. Name: {saved.Name}. Location: {saved.Tile.X},{saved.Tile.Y} ({saved.MapName})."); objectsToRemove.Add(saved); //mark this for removal from save } else if (saved.DaysUntilExpire > 1) //if this should expire, but not tonight { saved.DaysUntilExpire--; //decrease counter by 1 } } }