├── .gitignore ├── DedicatedServer 1.0.1.zip ├── DedicatedServer.sln ├── DedicatedServer ├── .gitignore ├── Chat │ ├── ChatEventArgs.cs │ └── EventDrivenChatBox.cs ├── Config │ └── ModConfig.cs ├── Crops │ └── CropSaver.cs ├── DedicatedServer.csproj ├── HostAutomatorStages │ ├── AutomatedHost.cs │ ├── BehaviorChain.cs │ ├── BehaviorLink.cs │ ├── BehaviorState.cs │ ├── CheckForParsnipSeedsBehaviorLink.cs │ ├── EndCommunityCenterBehaviorLink.cs │ ├── ExitFarmHouseBehaviorLink.cs │ ├── FestivalChatBox.cs │ ├── GetFishingRodBehaviorLink.cs │ ├── HostAutomatorStage.cs │ ├── ProcessDialogueBehaviorLink.cs │ ├── ProcessFestivalChatBoxBehaviorLink.cs │ ├── ProcessPauseBehaviorLink.cs │ ├── ProcessWaitTicksBehaviorLink.cs │ ├── PurchaseJojaMembershipBehaviorLink.cs │ ├── ReadyCheckHelper.cs │ ├── SkipEventsBehaviorLink.cs │ ├── SkipShippingMenuBehaviorLink.cs │ ├── StartFarmStage.cs │ ├── TransitionFestivalAttendanceBehaviorLink.cs │ ├── TransitionFestivalEndBehaviorLink.cs │ ├── TransitionSleepBehaviorLink.cs │ ├── UnlockCommunityCenterBehaviorLink.cs │ └── UpdateStateBehaviorLink.cs ├── MessageCommands │ ├── BuildCommandListener.cs │ ├── DemolishCommandListener.cs │ ├── PauseCommandListener.cs │ └── ServerCommandListener.cs ├── ModEntry.cs ├── Properties │ └── launchSettings.json ├── Utils │ ├── Festivals.cs │ ├── SerializableDictionary.cs │ └── Sleeping.cs └── manifest.json ├── LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .vs/ -------------------------------------------------------------------------------- /DedicatedServer 1.0.1.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ObjectManagerManager/SMAPIDedicatedServerMod/4975be17118646b0bce81d2dffb4174aa160412b/DedicatedServer 1.0.1.zip -------------------------------------------------------------------------------- /DedicatedServer.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.0.32014.148 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DedicatedServer", "DedicatedServer\DedicatedServer.csproj", "{AF8226D8-6F8A-44B7-8B9C-267E6FD33B7A}" 7 | EndProject 8 | Global 9 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 10 | Debug|Any CPU = Debug|Any CPU 11 | Release|Any CPU = Release|Any CPU 12 | EndGlobalSection 13 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 14 | {AF8226D8-6F8A-44B7-8B9C-267E6FD33B7A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 15 | {AF8226D8-6F8A-44B7-8B9C-267E6FD33B7A}.Debug|Any CPU.Build.0 = Debug|Any CPU 16 | {AF8226D8-6F8A-44B7-8B9C-267E6FD33B7A}.Release|Any CPU.ActiveCfg = Release|Any CPU 17 | {AF8226D8-6F8A-44B7-8B9C-267E6FD33B7A}.Release|Any CPU.Build.0 = Release|Any CPU 18 | EndGlobalSection 19 | GlobalSection(SolutionProperties) = preSolution 20 | HideSolutionNode = FALSE 21 | EndGlobalSection 22 | GlobalSection(ExtensibilityGlobals) = postSolution 23 | SolutionGuid = {872A9CA5-59CD-4C28-BA2A-048E4492D19A} 24 | EndGlobalSection 25 | EndGlobal 26 | -------------------------------------------------------------------------------- /DedicatedServer/.gitignore: -------------------------------------------------------------------------------- 1 | bin/ 2 | obj/ 3 | .vs/ -------------------------------------------------------------------------------- /DedicatedServer/Chat/ChatEventArgs.cs: -------------------------------------------------------------------------------- 1 | using StardewValley; 2 | 3 | namespace DedicatedServer.Chat 4 | { 5 | internal struct ChatEventArgs 6 | { 7 | public long SourceFarmerId { get; set; } 8 | public int ChatKind { get; set; } 9 | public LocalizedContentManager.LanguageCode LanguageCode { get; set; } 10 | public string Message { get; set; } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /DedicatedServer/Chat/EventDrivenChatBox.cs: -------------------------------------------------------------------------------- 1 | using StardewValley; 2 | using StardewValley.Menus; 3 | using System; 4 | using System.Collections.Generic; 5 | 6 | namespace DedicatedServer.Chat 7 | { 8 | internal class EventDrivenChatBox : ChatBox 9 | { 10 | public event EventHandler ChatReceived; 11 | private Dictionary, Action>>> farmerResponseActions = new Dictionary, Action>>>(); 12 | 13 | public EventDrivenChatBox() : base() 14 | { 15 | ChatReceived += tryResponseAction; 16 | } 17 | 18 | private void tryResponseAction(object sender, ChatEventArgs e) 19 | { 20 | if (e.ChatKind == 3 && 21 | farmerResponseActions.TryGetValue(e.SourceFarmerId, out var responseActionsForFarmer) && 22 | responseActionsForFarmer.TryGetValue(e.Message.ToLower(), out var responseAction)) { 23 | // Remove all response actions grouped with this response. This must be done 24 | // before executing the action, which could in-turn overwrite some of these 25 | // grouped responses. Otherwise, the overwritten one would be deleted. 26 | foreach (var groupedResponse in responseAction.Item1) 27 | { 28 | responseActionsForFarmer.Remove(groupedResponse); 29 | } 30 | 31 | // Execute the action if not null 32 | if (responseAction.Item2 != null) 33 | { 34 | responseAction.Item2(); 35 | } 36 | } 37 | } 38 | 39 | public override void receiveChatMessage(long sourceFarmer, int chatKind, LocalizedContentManager.LanguageCode language, string message) 40 | { 41 | base.receiveChatMessage(sourceFarmer, chatKind, language, message); 42 | if (ChatReceived != null) 43 | { 44 | var args = new ChatEventArgs 45 | { 46 | SourceFarmerId = sourceFarmer, 47 | ChatKind = chatKind, 48 | LanguageCode = language, 49 | Message = message 50 | }; 51 | ChatReceived(this, args); 52 | } 53 | } 54 | 55 | public void RegisterFarmerResponseActionGroup(long farmerId, Dictionary responseActions) 56 | { 57 | Dictionary, Action>> responseActionsForFarmer; 58 | if (farmerResponseActions.TryGetValue(farmerId, out responseActionsForFarmer)) 59 | { 60 | // Remove existing response groups for these farmer / responses. That is, 61 | // remove each of the responses as well as each of the responses grouped 62 | // with any of these responses. 63 | foreach (var response in responseActions.Keys) 64 | { 65 | if (responseActionsForFarmer.TryGetValue(response, out var responseActionGroup)) 66 | { 67 | foreach (var groupedResponse in responseActionGroup.Item1) 68 | { 69 | responseActionsForFarmer.Remove(groupedResponse); 70 | } 71 | } 72 | } 73 | } 74 | else 75 | { 76 | // The farmer does not yet have any response groups recorded; initialize 77 | // an empty dictionary for them 78 | farmerResponseActions.Add(farmerId, new Dictionary, Action>>()); 79 | responseActionsForFarmer = farmerResponseActions[farmerId]; 80 | } 81 | 82 | // Construct list of grouped response actions 83 | var responseGroup = new List(); 84 | foreach (var response in responseActions.Keys) 85 | { 86 | responseGroup.Add(response); 87 | } 88 | 89 | // Register all of the response actions 90 | foreach (var responseAction in responseActions) 91 | { 92 | responseActionsForFarmer[responseAction.Key] = new Tuple, Action>(responseGroup, responseAction.Value); 93 | } 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /DedicatedServer/Config/ModConfig.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace DedicatedServer.Config 4 | { 5 | public class ModConfig 6 | { 7 | public string FarmName { get; set; } = "Stardew"; 8 | 9 | // Options are 0, 1, 2, or 3. 10 | public int StartingCabins { get; set; } = 1; 11 | 12 | // Options are "nearby" or "separate" 13 | public string CabinLayout { get; set; } = "separate"; 14 | 15 | // Options are "normal", "75%", "50%", or "25%" 16 | public string ProfitMargin { get; set; } = "normal"; 17 | 18 | // Options are "shared" or "separate" 19 | public string MoneyStyle { get; set; } = "shared"; 20 | 21 | // Options are "standard", "riverland", "forest", "hilltop", "wilderness", "fourcorners", "beach". 22 | public string FarmType { get; set; } = "standard"; 23 | 24 | // Options are "normal" or "remixed". 25 | public string CommunityCenterBundles { get; set; } = "normal"; 26 | 27 | public bool GuaranteeYear1Completable { get; set; } = false; 28 | 29 | // Options are "normal" or "remixed". 30 | public string MineRewards { get; set; } = "normal"; 31 | 32 | public bool SpawnMonstersOnFarmAtNight { get; set; } = false; 33 | 34 | public ulong? RandomSeed { get; set; } = null; 35 | 36 | public bool AcceptPet = true; // By default, accept the pet (of course). 37 | 38 | // Nullable. Must not be null if AcceptPet is true. Options are "dog" or "cat". 39 | public string PetSpecies { get; set; } = "dog"; 40 | 41 | // Nullable. Must not be null if AcceptPet is true. Options are 0, 1, or 2. 42 | public int? PetBreed { get; set; } = 0; 43 | 44 | // Nullable. Must not be null if AcceptPet is true. Any string. 45 | public string PetName { get; set; } = "Stella"; 46 | 47 | // Options are "Mushrooms" or "Bats" (case-insensitive) 48 | public string MushroomsOrBats { get; set; } = "Mushrooms"; 49 | 50 | // Enables the crop saver 51 | public bool EnableCropSaver = true; 52 | 53 | // Configures the automated host to purchase a Joja membership once available, 54 | // committing to the Joja route and removing the community center. 55 | public bool PurchaseJojaMembership = false; 56 | 57 | // Changes farmhands permissions to move buildings from the Carpenter's Shop. 58 | // Is set each time the server is started and can be changed in the game. 59 | // "off" to entirely disable moving buildings. 60 | // "owned" to allow farmhands to move buildings that they purchased. 61 | // "on" to allow moving all buildings. 62 | public string MoveBuildPermission { get; set; } = "off"; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /DedicatedServer/Crops/CropSaver.cs: -------------------------------------------------------------------------------- 1 | using DedicatedServer.Config; 2 | using StardewModdingAPI; 3 | using StardewValley; 4 | using StardewValley.Locations; 5 | using StardewValley.TerrainFeatures; 6 | using System; 7 | using System.Collections.Generic; 8 | using System.IO; 9 | using System.Linq; 10 | using System.Xml.Serialization; 11 | 12 | namespace DedicatedServer.Crops 13 | { 14 | public class CropSaver 15 | { 16 | private IModHelper helper; 17 | private IMonitor monitor; 18 | private ModConfig config; 19 | private SerializableDictionary cropDictionary = new SerializableDictionary(); 20 | private SerializableDictionary beginningOfDayCrops = new SerializableDictionary(); 21 | private XmlSerializer cropSaveDataSerializer = new XmlSerializer(typeof(CropSaveData)); 22 | 23 | public struct CropSaveData 24 | { 25 | public SerializableDictionary cropDictionary { get; set; } 26 | public SerializableDictionary beginningOfDayCrops { get; set; } 27 | } 28 | 29 | public struct CropLocation 30 | { 31 | public string LocationName { get; set; } 32 | public int TileX { get; set; } 33 | public int TileY { get; set; } 34 | } 35 | 36 | public struct CropGrowthStage 37 | { 38 | public int CurrentPhase { get; set; } 39 | public int DayOfCurrentPhase { get; set; } 40 | public bool FullyGrown { get; set; } 41 | public List PhaseDays { get; set; } 42 | public int OriginalRegrowAfterHarvest { get; set; } 43 | } 44 | 45 | public struct CropComparisonData 46 | { 47 | public CropGrowthStage CropGrowthStage { get; set; } 48 | public int RowInSpriteSheet { get; set; } 49 | public bool Dead { get; set; } 50 | public bool ForageCrop { get; set; } 51 | public int WhichForageCrop { get; set; } 52 | } 53 | 54 | public struct CropData 55 | { 56 | public bool MarkedForDeath { get; set; } 57 | public List OriginalSeasonsToGrowIn { get; set; } 58 | public bool HasExistedInIncompatibleSeason { get; set; } 59 | public int OriginalRegrowAfterHarvest { get; set; } 60 | public bool HarvestableLastNight { get; set; } 61 | } 62 | 63 | public CropSaver(IModHelper helper, IMonitor monitor, ModConfig config) 64 | { 65 | this.helper = helper; 66 | this.monitor = monitor; 67 | this.config = config; 68 | } 69 | 70 | public void Enable() 71 | { 72 | helper.Events.GameLoop.DayStarted += onDayStarted; 73 | helper.Events.GameLoop.DayEnding += onDayEnding; 74 | helper.Events.GameLoop.Saving += onSaving; 75 | helper.Events.GameLoop.SaveLoaded += onLoaded; 76 | } 77 | 78 | private void onLoaded(object sender, StardewModdingAPI.Events.SaveLoadedEventArgs e) 79 | { 80 | /** 81 | * Loads the cropDictionary and beginningOfDayCrops. 82 | */ 83 | string str = SaveGame.FilterFileName(Game1.GetSaveGameName()); 84 | string filenameNoTmpString = str + "_" + Game1.uniqueIDForThisGame; 85 | string save_directory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "StardewValley", "Saves", filenameNoTmpString + Path.DirectorySeparatorChar); 86 | if (Game1.savePathOverride != "") 87 | { 88 | save_directory = Game1.savePathOverride; 89 | } 90 | string saveFile = Path.Combine(save_directory, "AdditionalCropData"); 91 | 92 | // Deserialize crop data from temp save file 93 | Stream fstream = null; 94 | try 95 | { 96 | fstream = new FileStream(saveFile, FileMode.Open); 97 | CropSaveData cropSaveData = (CropSaveData)cropSaveDataSerializer.Deserialize(fstream); 98 | fstream.Close(); 99 | beginningOfDayCrops = cropSaveData.beginningOfDayCrops; 100 | cropDictionary = cropSaveData.cropDictionary; 101 | } catch (IOException) 102 | { 103 | fstream?.Close(); 104 | } 105 | } 106 | 107 | private void onSaving(object sender, StardewModdingAPI.Events.SavingEventArgs e) 108 | { 109 | /** 110 | * Saves the cropDictionary and beginningOfDayCrops. In most cases, the day is started 111 | * immediately after loading, which in-turn clears beginningOfDayCrops. However, in case 112 | * some other mod is installed which allows mid-day saving and loading, it's a good idea 113 | * to save both dictionaries anyways. 114 | */ 115 | 116 | // Determine save paths 117 | string tmpString = "_STARDEWVALLEYSAVETMP"; 118 | bool save_backups_and_metadata = true; 119 | string str = SaveGame.FilterFileName(Game1.GetSaveGameName()); 120 | string filenameNoTmpString = str + "_" + Game1.uniqueIDForThisGame; 121 | string filenameWithTmpString = str + "_" + Game1.uniqueIDForThisGame + tmpString; 122 | string save_directory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "StardewValley", "Saves", filenameNoTmpString + Path.DirectorySeparatorChar); 123 | if (Game1.savePathOverride != "") 124 | { 125 | save_directory = Game1.savePathOverride; 126 | if (Game1.savePathOverride != "") 127 | { 128 | save_backups_and_metadata = false; 129 | } 130 | } 131 | SaveGame.ensureFolderStructureExists(); 132 | string tmpSaveFile = Path.Combine(save_directory, "AdditionalCropData" + tmpString); 133 | string saveFile = Path.Combine(save_directory, "AdditionalCropData"); 134 | string backupSaveFile = Path.Combine(save_directory, "AdditionalCropData_old"); 135 | 136 | // Serialize crop data to temp save file 137 | TextWriter writer = null; 138 | try 139 | { 140 | writer = new StreamWriter(tmpSaveFile); 141 | } 142 | catch (IOException) 143 | { 144 | writer?.Close(); 145 | } 146 | 147 | cropSaveDataSerializer.Serialize(writer, new CropSaveData {cropDictionary = cropDictionary, beginningOfDayCrops = beginningOfDayCrops}); 148 | writer.Close(); 149 | 150 | // If appropriate, move old crop data file to backup 151 | if (save_backups_and_metadata) 152 | { 153 | try 154 | { 155 | if (File.Exists(backupSaveFile)) 156 | { 157 | File.Delete(backupSaveFile); 158 | } 159 | } 160 | catch (Exception) {} 161 | 162 | try 163 | { 164 | File.Move(saveFile, backupSaveFile); 165 | } 166 | catch (Exception) {} 167 | } 168 | 169 | // Delete previous save file if it still exists (hasn't been moved to 170 | // backup) 171 | if (File.Exists(saveFile)) 172 | { 173 | File.Delete(saveFile); 174 | } 175 | 176 | // Move new temp save file to non-temp save file 177 | try 178 | { 179 | File.Move(tmpSaveFile, saveFile); 180 | } 181 | catch (IOException ex) 182 | { 183 | Game1.debugOutput = Game1.parseText(ex.Message); 184 | } 185 | } 186 | 187 | private static bool sameCrop(CropComparisonData first, CropComparisonData second) 188 | { 189 | // Two crops are considered "different" if they have different sprite sheet rows (i.e., they're 190 | // different crop types); one of them is dead while the other is alive; one is a forage crop 191 | // while the other is not; the two crops are different types of forage crops; their phases 192 | // of growth are different; or their current days of growth are different, except when 193 | // one of them is harvestable and the other is fully grown and harvested. A crop is considered 194 | // harvestable when it's in the last stage of growth, and its either set to not "FullyGrown", or 195 | // its day of current phase is less than or equal to zero (after the first harvest, its day of 196 | // current phase works downward). A crop is considered harvested when it's in the final phase 197 | // and the above sub-conditions aren't satisfied (it's set to FullyGrown and its day of current 198 | // phase is positive) 199 | var differentSprites = first.RowInSpriteSheet != second.RowInSpriteSheet; 200 | 201 | var differentDeads = first.Dead != second.Dead; 202 | 203 | var differentForages = first.ForageCrop != second.ForageCrop; 204 | 205 | var differentForageTypes = first.WhichForageCrop != second.WhichForageCrop; 206 | 207 | var differentPhases = first.CropGrowthStage.CurrentPhase != second.CropGrowthStage.CurrentPhase; 208 | 209 | var differentDays = first.CropGrowthStage.DayOfCurrentPhase != second.CropGrowthStage.DayOfCurrentPhase; 210 | var firstGrown = first.CropGrowthStage.CurrentPhase >= first.CropGrowthStage.PhaseDays.Count - 1; 211 | var secondGrown = second.CropGrowthStage.CurrentPhase >= second.CropGrowthStage.PhaseDays.Count - 1; 212 | var firstHarvestable = firstGrown && (first.CropGrowthStage.DayOfCurrentPhase <= 0 || !first.CropGrowthStage.FullyGrown); 213 | var secondHarvestable = secondGrown && (second.CropGrowthStage.DayOfCurrentPhase <= 0 || !second.CropGrowthStage.FullyGrown); 214 | var firstRegrown = firstGrown && !firstHarvestable; 215 | var secondRegrown = secondGrown && !secondHarvestable; 216 | var harvestableAndRegrown = (firstHarvestable && secondRegrown) || (firstRegrown && secondHarvestable); 217 | var differentMeaningfulDays = differentDays && !harvestableAndRegrown; 218 | 219 | return !differentSprites && !differentDeads && !differentForages && !differentForageTypes && !differentPhases && !differentMeaningfulDays; 220 | } 221 | 222 | private void onDayEnding(object sender, StardewModdingAPI.Events.DayEndingEventArgs e) 223 | { 224 | // In order to check for crops that have been destroyed and need to be removed from 225 | // the cropDictionary all together, we need to keep track of which crop locations 226 | // from the cropDictionary are found during the iteration over all crops in all 227 | // locations. Any which are not found must no longer exist (and have not been 228 | // replaced) and can be removed. 229 | var locationSet = new HashSet(); 230 | foreach (var location in Game1.locations) 231 | { 232 | if (location.IsOutdoors && !location.SeedsIgnoreSeasonsHere() && !(location is IslandLocation)) 233 | { 234 | // Found an outdoor location where seeds don't ignore seasons. Find all the 235 | // crops here to cache necessary data for protecting them. 236 | foreach (var pair in location.terrainFeatures.Pairs) 237 | { 238 | var tileLocation = pair.Key; 239 | var terrainFeature = pair.Value; 240 | if (terrainFeature is HoeDirt) 241 | { 242 | var hoeDirt = terrainFeature as HoeDirt; 243 | var crop = hoeDirt.crop; 244 | if (crop != null) 245 | { 246 | // Found a crop. Construct a CropLocation key 247 | var cropLocation = new CropLocation 248 | { 249 | LocationName = location.NameOrUniqueName, 250 | TileX = (int)tileLocation.X, 251 | TileY = (int)tileLocation.Y 252 | }; 253 | 254 | // Mark it as found via the locationSet, so we know not to remove 255 | // the corresponding cropDictionary entry if one exists 256 | locationSet.Add(cropLocation); 257 | 258 | // Construct its growth stage so we can compare it to beginningOfDayCrops 259 | // to see if it was newly-planted. 260 | var cropGrowthStage = new CropGrowthStage 261 | { 262 | CurrentPhase = crop.currentPhase.Value, 263 | DayOfCurrentPhase = crop.dayOfCurrentPhase.Value, 264 | FullyGrown = crop.fullyGrown.Value, 265 | PhaseDays = crop.phaseDays.ToList(), 266 | OriginalRegrowAfterHarvest = crop.regrowAfterHarvest.Value 267 | }; 268 | 269 | var cropComparisonData = new CropComparisonData 270 | { 271 | CropGrowthStage = cropGrowthStage, 272 | RowInSpriteSheet = crop.rowInSpriteSheet.Value, 273 | Dead = crop.dead.Value, 274 | ForageCrop = crop.forageCrop.Value, 275 | WhichForageCrop = crop.whichForageCrop.Value 276 | }; 277 | 278 | // Determine if this crop was planted today or was pre-existing, based on whether 279 | // or not it's different from the crop at this location at the beginning of the day. 280 | if (!beginningOfDayCrops.ContainsKey(cropLocation) || !sameCrop(beginningOfDayCrops[cropLocation], cropComparisonData)) 281 | { 282 | // No crop was found at this location at the beginning of the day, or the comparison data 283 | // is different. Consider it a new crop, and add a new CropData for it in the cropDictionary. 284 | var cd = new CropData 285 | { 286 | MarkedForDeath = false, 287 | OriginalSeasonsToGrowIn = crop.seasonsToGrowIn.ToList(), 288 | HasExistedInIncompatibleSeason = false, 289 | OriginalRegrowAfterHarvest = crop.regrowAfterHarvest.Value, 290 | HarvestableLastNight = false 291 | }; 292 | cropDictionary[cropLocation] = cd; 293 | 294 | // Make sure that the crop is set to survive in all seasons, so that it 295 | // only dies if it's harvested for the last time or manually killed after being 296 | // marked for death 297 | if (!crop.seasonsToGrowIn.Contains("spring")) 298 | { 299 | crop.seasonsToGrowIn.Add("spring"); 300 | } 301 | if (!crop.seasonsToGrowIn.Contains("summer")) 302 | { 303 | crop.seasonsToGrowIn.Add("summer"); 304 | } 305 | if (!crop.seasonsToGrowIn.Contains("fall")) 306 | { 307 | crop.seasonsToGrowIn.Add("fall"); 308 | } 309 | if (!crop.seasonsToGrowIn.Contains("winter")) 310 | { 311 | crop.seasonsToGrowIn.Add("winter"); 312 | } 313 | } 314 | 315 | // If there's a crop in the dictionary at this location (just planted today or otherwise), 316 | // record whether it's harvestable tonight. This is used to help determine whether the crop 317 | // should be marked for death the next morning. A crop is harvestable if and only if it's 318 | // in the last phase, AND it's either a) NOT marked as "fully grown" (i.e., it hasn't been harvested 319 | // at least once), or b) has a non-positive current day of phase (after harvest and regrowth, 320 | // the current day of phase is set to positive and then works downward; 0 means ready-for-reharvest). 321 | if (cropDictionary.TryGetValue(cropLocation, out var cropData)) 322 | { 323 | if ((crop.phaseDays.Count > 0 && crop.currentPhase.Value < crop.phaseDays.Count - 1) || (crop.dayOfCurrentPhase.Value > 0 && crop.fullyGrown.Value)) 324 | { 325 | cropData.HarvestableLastNight = false; 326 | } else 327 | { 328 | cropData.HarvestableLastNight = true; 329 | } 330 | cropDictionary[cropLocation] = cropData; 331 | } 332 | } 333 | } 334 | } 335 | } 336 | } 337 | 338 | // Lastly, if there were any CropLocations in the cropDictionary that we DIDN'T see throughout the entire 339 | // iteration, then they must've been destroyed, AND they weren't replaced with a new crop at the same location. 340 | // In such a case, we can remove it from the cropDictionary. 341 | var locationSetComplement = new HashSet(); 342 | foreach (var kvp in cropDictionary) 343 | { 344 | if (!locationSet.Contains(kvp.Key)) 345 | { 346 | locationSetComplement.Add(kvp.Key); 347 | } 348 | } 349 | foreach (var cropLocation in locationSetComplement) 350 | { 351 | cropDictionary.Remove(cropLocation); 352 | } 353 | } 354 | 355 | private void onDayStarted(object sender, StardewModdingAPI.Events.DayStartedEventArgs e) 356 | { 357 | beginningOfDayCrops.Clear(); 358 | foreach (var location in Game1.locations) 359 | { 360 | if (location.IsOutdoors && !location.SeedsIgnoreSeasonsHere() && location is not IslandLocation) 361 | { 362 | // Found an outdoor location where seeds don't ignore seasons. Find all the 363 | // crops here to cache necessary data for protecting them. 364 | foreach (var pair in location.terrainFeatures.Pairs) 365 | { 366 | var tileLocation = pair.Key; 367 | var terrainFeature = pair.Value; 368 | if (terrainFeature is HoeDirt) 369 | { 370 | var hoeDirt = terrainFeature as HoeDirt; 371 | var crop = hoeDirt.crop; 372 | if (crop != null) { 373 | // Found a crop. Construct a CropLocation key 374 | var cropLocation = new CropLocation 375 | { 376 | LocationName = location.NameOrUniqueName, 377 | TileX = (int) tileLocation.X, 378 | TileY = (int) tileLocation.Y 379 | }; 380 | 381 | CropData cropData; 382 | CropComparisonData cropComparisonData; 383 | // Now, we have to update the properties of the CropData entry 384 | // in the cropDictionary. Firstly, check if such a CropData entry exists 385 | // (it won't exist for auto-spawned crops, like spring onion, since they'll 386 | // never have passed the previous "newly planted test") 387 | if (!cropDictionary.TryGetValue(cropLocation, out cropData)) 388 | { 389 | // The crop was not planted by the player. However, we do want to 390 | // record its comparison information so that we can check this evening 391 | // if it has changed, which would indicate that it HAS been replaced 392 | // by a player-planted crop. 393 | 394 | var cgs = new CropGrowthStage 395 | { 396 | CurrentPhase = crop.currentPhase.Value, 397 | DayOfCurrentPhase = crop.dayOfCurrentPhase.Value, 398 | FullyGrown = crop.fullyGrown.Value, 399 | PhaseDays = crop.phaseDays.ToList(), 400 | OriginalRegrowAfterHarvest = crop.regrowAfterHarvest.Value 401 | }; 402 | 403 | cropComparisonData = new CropComparisonData 404 | { 405 | CropGrowthStage = cgs, 406 | RowInSpriteSheet = crop.rowInSpriteSheet.Value, 407 | Dead = crop.dead.Value, 408 | ForageCrop = crop.forageCrop.Value, 409 | WhichForageCrop = crop.whichForageCrop.Value 410 | }; 411 | 412 | beginningOfDayCrops[cropLocation] = cropComparisonData; 413 | 414 | // Now move on to the next crop; we don't want to mess with this one. 415 | continue; 416 | } 417 | 418 | // As of last night, the crop at this location was considered to have been 419 | // planted by the player. Let's hope that it hasn't somehow been replaced 420 | // by an entirely different crop overnight; though that seems unlikely. 421 | 422 | // Check if it's currently a season which is incompatible with the 423 | // crop's ORIGINAL compatible seasons. If so, update the crop data to 424 | // reflect this. 425 | if (!cropData.OriginalSeasonsToGrowIn.Contains(location.GetSeasonForLocation())) 426 | { 427 | cropData.HasExistedInIncompatibleSeason = true; 428 | } 429 | 430 | // Check if the crop has been out of season, AND it was not harvestable last night. 431 | // If so, mark it for death. This covers the edge case of when a crop finishes 432 | // growing on the first day in which it's out-of-season (it should be marked for 433 | // death, in this case). 434 | 435 | if (cropData.HasExistedInIncompatibleSeason && !cropData.HarvestableLastNight) 436 | { 437 | cropData.MarkedForDeath = true; 438 | } 439 | 440 | // Now we have to update the crop itself. If it's existed out-of-season, 441 | // then its regrowAfterHarvest value should be set to -1, so that the 442 | // farmer only gets one more harvest out of it. 443 | if (cropData.HasExistedInIncompatibleSeason) 444 | { 445 | crop.regrowAfterHarvest.Value = -1; 446 | } 447 | 448 | // And if the crop has been marked for death because it was planted too close to 449 | // the turn of the season, then we should make sure it's killed. 450 | if (cropData.MarkedForDeath) 451 | { 452 | crop.Kill(); 453 | } 454 | 455 | // Update the crop data in the crop dictionary 456 | cropDictionary[cropLocation] = cropData; 457 | 458 | // Lastly, now that the crop has been updated, construct the comparison data for later 459 | // so that we can check if this has been replaced by a newly planted crop in the evening. 460 | 461 | var cropGrowthStage = new CropGrowthStage 462 | { 463 | CurrentPhase = crop.currentPhase.Value, 464 | DayOfCurrentPhase = crop.dayOfCurrentPhase.Value, 465 | FullyGrown = crop.fullyGrown.Value, 466 | PhaseDays = crop.phaseDays.ToList(), 467 | OriginalRegrowAfterHarvest = cropData.OriginalRegrowAfterHarvest 468 | }; 469 | cropComparisonData = new CropComparisonData 470 | { 471 | CropGrowthStage = cropGrowthStage, 472 | RowInSpriteSheet = crop.rowInSpriteSheet.Value, 473 | Dead = crop.dead.Value, 474 | ForageCrop = crop.forageCrop.Value, 475 | WhichForageCrop = crop.whichForageCrop.Value 476 | }; 477 | 478 | beginningOfDayCrops[cropLocation] = cropComparisonData; 479 | } 480 | } 481 | } 482 | } 483 | } 484 | } 485 | } 486 | } 487 | -------------------------------------------------------------------------------- /DedicatedServer/DedicatedServer.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net5.0 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /DedicatedServer/HostAutomatorStages/AutomatedHost.cs: -------------------------------------------------------------------------------- 1 | using DedicatedServer.Chat; 2 | using DedicatedServer.Config; 3 | using StardewModdingAPI; 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Linq; 7 | using System.Text; 8 | using System.Threading.Tasks; 9 | 10 | namespace DedicatedServer.HostAutomatorStages 11 | { 12 | internal class AutomatedHost 13 | { 14 | private IModHelper helper; 15 | private BehaviorChain behaviorChain; 16 | private BehaviorState behaviorState; 17 | 18 | public AutomatedHost(IModHelper helper, IMonitor monitor, ModConfig config, EventDrivenChatBox chatBox) 19 | { 20 | behaviorChain = new BehaviorChain(helper, monitor, config, chatBox); 21 | behaviorState = new BehaviorState(monitor, chatBox); 22 | this.helper = helper; 23 | } 24 | 25 | public void Enable() 26 | { 27 | helper.Events.GameLoop.UpdateTicked += OnUpdate; 28 | helper.Events.GameLoop.DayStarted += OnNewDay; 29 | } 30 | 31 | public void Disable() 32 | { 33 | helper.Events.GameLoop.UpdateTicked -= OnUpdate; 34 | helper.Events.GameLoop.DayStarted -= OnNewDay; 35 | } 36 | 37 | private void OnNewDay(object sender, StardewModdingAPI.Events.DayStartedEventArgs e) 38 | { 39 | behaviorState.NewDay(); 40 | } 41 | 42 | private void OnUpdate(object sender, StardewModdingAPI.Events.UpdateTickedEventArgs e) 43 | { 44 | behaviorChain.Process(behaviorState); 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /DedicatedServer/HostAutomatorStages/BehaviorChain.cs: -------------------------------------------------------------------------------- 1 | using DedicatedServer.Chat; 2 | using DedicatedServer.Config; 3 | using StardewModdingAPI; 4 | using StardewValley; 5 | using System; 6 | using System.Collections.Generic; 7 | using System.Linq; 8 | using System.Reflection; 9 | using System.Text; 10 | using System.Threading.Tasks; 11 | 12 | namespace DedicatedServer.HostAutomatorStages 13 | { 14 | internal class BehaviorChain 15 | { 16 | private BehaviorLink head; 17 | 18 | public BehaviorChain(IModHelper helper, IMonitor monitor, ModConfig config, EventDrivenChatBox chatBox) 19 | { 20 | // 1. Perform prerequisite per-tick state updates, such as detecting the number of other players online 21 | // (this is a non-blocking chain link; the process will always follow through to the next link). 22 | // 2. Transition the game pause state 23 | // 3. Process wait ticks 24 | // 4. Skip skippable events 25 | // 5. Respond to dialogue box question if present, skipping non-question dialogue 26 | // 6. Skip shipping menu 27 | // 7. If in farmhouse and haven't checked for parsnip seeds, check for parsnip seeds 28 | // 8. If in farmhouse and haven't left farmhouse for the day, leave farmhouse 29 | // 9. If we don't have the fishing rod yet, and it's available, get it. 30 | // 10. If we haven't unlocked the community center yet, and we can, then unlock it. 31 | // 12. If we haven't watched the end cutscene for the community scenter yet, and we can, then watch it. 32 | // 13. If our sleep state should be switched, then switch it 33 | // 14. If our state of festival attendance should be switched, then switch it 34 | // 15. If our leave festival state should be switched, then switch it 35 | // 16. If we're at a festival and we need to watch the festival chatbox, then watch it 36 | 37 | var chain = new BehaviorLink[] { 38 | new UpdateStateBehaviorLink(), 39 | new ProcessPauseBehaviorLink(), 40 | new ProcessWaitTicksBehaviorLink(), 41 | new SkipEventsBehaviorLink(), 42 | new ProcessDialogueBehaviorLink(config), 43 | new SkipShippingMenuBehaviorLink(), 44 | new CheckForParsnipSeedsBehaviorLink(), 45 | new ExitFarmHouseBehaviorLink(), 46 | new GetFishingRodBehaviorLink(), 47 | new UnlockCommunityCenterBehaviorLink(), 48 | new PurchaseJojaMembershipBehaviorLink(config), 49 | new EndCommunityCenterBehaviorLink(), 50 | new TransitionSleepBehaviorLink(), 51 | new TransitionFestivalAttendanceBehaviorLink(), 52 | new TransitionFestivalEndBehaviorLink(), 53 | new ProcessFestivalChatBoxBehaviorLink() 54 | }; 55 | // Build chain and set head 56 | for (int i = 0; i < chain.Length - 1; i++) 57 | { 58 | chain[i].SetNext(chain[i + 1]); 59 | } 60 | head = chain[0]; 61 | } 62 | 63 | public void Process(BehaviorState state) 64 | { 65 | head.Process(state); 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /DedicatedServer/HostAutomatorStages/BehaviorLink.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | 7 | namespace DedicatedServer.HostAutomatorStages 8 | { 9 | internal abstract class BehaviorLink 10 | { 11 | private BehaviorLink next; 12 | 13 | public BehaviorLink(BehaviorLink next = null) 14 | { 15 | this.next = next; 16 | } 17 | 18 | public void SetNext(BehaviorLink next) 19 | { 20 | this.next = next; 21 | } 22 | 23 | public abstract void Process(BehaviorState state); 24 | protected void processNext(BehaviorState state) 25 | { 26 | if (next != null) 27 | { 28 | next.Process(state); 29 | } 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /DedicatedServer/HostAutomatorStages/BehaviorState.cs: -------------------------------------------------------------------------------- 1 | using DedicatedServer.Chat; 2 | using StardewModdingAPI; 3 | using StardewValley; 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Linq; 7 | using System.Reflection; 8 | using System.Text; 9 | using System.Threading.Tasks; 10 | 11 | namespace DedicatedServer.HostAutomatorStages 12 | { 13 | internal class BehaviorState 14 | { 15 | private const int startOfDayWaitTicks = 60; 16 | private static FieldInfo multiplayerFieldInfo = typeof(Game1).GetField("multiplayer", BindingFlags.NonPublic | BindingFlags.Static); 17 | private static Multiplayer multiplayer = null; 18 | 19 | private int betweenEventsWaitTicks = 0; 20 | private int betweenDialoguesWaitTicks = 0; 21 | private int betweenShippingMenusWaitTicks = 0; 22 | private bool checkedForParsnipSeeds = false; 23 | private bool exitedFarmhouse = false; 24 | private int betweenTransitionSleepWaitTicks = 0; 25 | private int betweenTransitionFestivalAttendanceWaitTicks = 0; 26 | private int betweenTransitionFestivalEndWaitTicks = 0; 27 | private int waitTicks = startOfDayWaitTicks; 28 | private int numFestivalStartVotes = 0; 29 | private int numFestivalStartVotesRequired = 0; 30 | private IDictionary otherPlayers = new Dictionary(); 31 | private IMonitor monitor; 32 | private FestivalChatBox festivalChatBox; 33 | 34 | public BehaviorState(IMonitor monitor, EventDrivenChatBox chatBox) 35 | { 36 | this.monitor = monitor; 37 | festivalChatBox = new FestivalChatBox(chatBox, otherPlayers); 38 | } 39 | 40 | public bool HasBetweenEventsWaitTicks() 41 | { 42 | return betweenEventsWaitTicks > 0; 43 | } 44 | public void DecrementBetweenEventsWaitTicks() 45 | { 46 | betweenEventsWaitTicks--; 47 | } 48 | public void SkipEvent() 49 | { 50 | betweenEventsWaitTicks = (int)(600 * 0.2); 51 | } 52 | public void ClearBetweenEventsWaitTicks() 53 | { 54 | betweenEventsWaitTicks = 0; 55 | } 56 | 57 | public bool HasBetweenDialoguesWaitTicks() 58 | { 59 | return betweenDialoguesWaitTicks > 0; 60 | } 61 | public void DecrementBetweenDialoguesWaitTicks() 62 | { 63 | betweenDialoguesWaitTicks--; 64 | } 65 | public void SkipDialogue() 66 | { 67 | betweenDialoguesWaitTicks = (int)(60 * 0.2); 68 | } 69 | public void ClearBetweenDialoguesWaitTicks() 70 | { 71 | betweenDialoguesWaitTicks = 0; 72 | } 73 | 74 | public bool HasBetweenShippingMenusWaitTicks() 75 | { 76 | return betweenShippingMenusWaitTicks > 0; 77 | } 78 | public void DecrementBetweenShippingMenusWaitTicks() 79 | { 80 | betweenShippingMenusWaitTicks--; 81 | } 82 | public void SkipShippingMenu() 83 | { 84 | betweenShippingMenusWaitTicks = 60; 85 | } 86 | public void ClearBetweenShippingMenusWaitTicks() 87 | { 88 | betweenShippingMenusWaitTicks = 0; 89 | } 90 | 91 | public bool HasCheckedForParsnipSeeds() 92 | { 93 | return checkedForParsnipSeeds; 94 | } 95 | public void CheckForParsnipSeeds() 96 | { 97 | checkedForParsnipSeeds = true; 98 | } 99 | 100 | public bool ExitedFarmhouse() 101 | { 102 | return exitedFarmhouse; 103 | } 104 | public void ExitFarmhouse() 105 | { 106 | exitedFarmhouse = true; 107 | } 108 | 109 | public bool HasBetweenTransitionSleepWaitTicks() 110 | { 111 | return betweenTransitionSleepWaitTicks > 0; 112 | } 113 | public void DecrementBetweenTransitionSleepWaitTicks() 114 | { 115 | betweenTransitionSleepWaitTicks--; 116 | } 117 | public void Sleep() 118 | { 119 | betweenTransitionSleepWaitTicks = (int)(60 * 0.2); 120 | } 121 | public void WarpToSleep() 122 | { 123 | betweenTransitionSleepWaitTicks = 60; 124 | } 125 | public void CancelSleep() 126 | { 127 | betweenTransitionSleepWaitTicks = (int)(60 * 0.2); 128 | } 129 | public void ClearBetweenTransitionSleepWaitTicks() 130 | { 131 | betweenTransitionSleepWaitTicks = 0; 132 | } 133 | 134 | public bool HasBetweenTransitionFestivalAttendanceWaitTicks() 135 | { 136 | return betweenTransitionFestivalAttendanceWaitTicks > 0; 137 | } 138 | public void DecrementBetweenTransitionFestivalAttendanceWaitTicks() 139 | { 140 | betweenTransitionFestivalAttendanceWaitTicks--; 141 | } 142 | public void WaitForFestivalAttendance() 143 | { 144 | betweenTransitionFestivalAttendanceWaitTicks = (int)(60 * 0.2); 145 | } 146 | public void StopWaitingForFestivalAttendance() 147 | { 148 | betweenTransitionFestivalAttendanceWaitTicks = (int)(60 * 0.2); 149 | } 150 | public void ClearBetweenTransitionFestivalAttendanceWaitTicks() 151 | { 152 | betweenTransitionFestivalAttendanceWaitTicks = 0; 153 | } 154 | 155 | public bool HasBetweenTransitionFestivalEndWaitTicks() 156 | { 157 | return betweenTransitionFestivalEndWaitTicks > 0; 158 | } 159 | public void DecrementBetweenTransitionFestivalEndWaitTicks() 160 | { 161 | betweenTransitionFestivalEndWaitTicks--; 162 | } 163 | public void WaitForFestivalEnd() 164 | { 165 | betweenTransitionFestivalEndWaitTicks = (int)(60 * 0.2); 166 | } 167 | public void StopWaitingForFestivalEnd() 168 | { 169 | betweenTransitionFestivalEndWaitTicks = (int)(60 * 0.2); 170 | } 171 | public void ClearBetweenTransitionFestivalEndWaitTicks() 172 | { 173 | betweenTransitionFestivalEndWaitTicks = 0; 174 | } 175 | 176 | public bool HasWaitTicks() 177 | { 178 | return waitTicks > 0; 179 | } 180 | public void SetWaitTicks(int waitTicks) 181 | { 182 | this.waitTicks = waitTicks; 183 | } 184 | public void DecrementWaitTicks() 185 | { 186 | waitTicks--; 187 | } 188 | public void ClearWaitTicks() 189 | { 190 | waitTicks = 0; 191 | } 192 | 193 | public Tuple UpdateFestivalStartVotes() 194 | { 195 | if (festivalChatBox.IsEnabled()) 196 | { 197 | int numFestivalStartVotes = festivalChatBox.NumVoted(); 198 | if (numFestivalStartVotes != this.numFestivalStartVotes || otherPlayers.Count != numFestivalStartVotesRequired) 199 | { 200 | this.numFestivalStartVotes = numFestivalStartVotes; 201 | numFestivalStartVotesRequired = otherPlayers.Count; 202 | return Tuple.Create(numFestivalStartVotes, numFestivalStartVotesRequired); 203 | } 204 | } 205 | return null; 206 | } 207 | 208 | public void EnableFestivalChatBox() 209 | { 210 | festivalChatBox.Enable(); 211 | numFestivalStartVotes = 0; 212 | numFestivalStartVotesRequired = otherPlayers.Count; 213 | } 214 | public void DisableFestivalChatBox() 215 | { 216 | festivalChatBox.Disable(); 217 | } 218 | public void SendChatMessage(string message) 219 | { 220 | festivalChatBox.SendChatMessage(message); 221 | } 222 | 223 | public int GetNumOtherPlayers() 224 | { 225 | return otherPlayers.Count; 226 | } 227 | public IDictionary GetOtherPlayers() 228 | { 229 | return otherPlayers; 230 | } 231 | public void UpdateOtherPlayers() 232 | { 233 | if (multiplayer == null) 234 | { 235 | multiplayer = (Multiplayer)multiplayerFieldInfo.GetValue(null); 236 | } 237 | otherPlayers.Clear(); 238 | foreach (var farmer in Game1.otherFarmers.Values) 239 | { 240 | if (!multiplayer.isDisconnecting(farmer)) 241 | { 242 | otherPlayers.Add(farmer.UniqueMultiplayerID, farmer); 243 | } 244 | } 245 | } 246 | 247 | public void LogDebug(string s) 248 | { 249 | monitor.Log(s, LogLevel.Debug); 250 | } 251 | 252 | public void NewDay() 253 | { 254 | betweenEventsWaitTicks = 0; 255 | betweenDialoguesWaitTicks = 0; 256 | betweenShippingMenusWaitTicks = 0; 257 | checkedForParsnipSeeds = false; 258 | exitedFarmhouse = false; 259 | betweenTransitionSleepWaitTicks = 0; 260 | betweenTransitionFestivalAttendanceWaitTicks = 0; 261 | betweenTransitionFestivalEndWaitTicks = 0; 262 | waitTicks = startOfDayWaitTicks; 263 | numFestivalStartVotes = 0; 264 | numFestivalStartVotesRequired = otherPlayers.Count; 265 | } 266 | } 267 | } 268 | -------------------------------------------------------------------------------- /DedicatedServer/HostAutomatorStages/CheckForParsnipSeedsBehaviorLink.cs: -------------------------------------------------------------------------------- 1 | using DedicatedServer.Chat; 2 | using DedicatedServer.Config; 3 | using StardewModdingAPI; 4 | using StardewModdingAPI.Events; 5 | using StardewValley; 6 | using StardewValley.Locations; 7 | using StardewValley.Menus; 8 | using StardewValley.Objects; 9 | using System.Collections.Generic; 10 | using System.Linq; 11 | using System.Reflection; 12 | 13 | namespace DedicatedServer.HostAutomatorStages 14 | { 15 | internal class CheckForParsnipSeedsBehaviorLink : BehaviorLink 16 | { 17 | public CheckForParsnipSeedsBehaviorLink(BehaviorLink next = null) : base(next) 18 | { 19 | } 20 | 21 | public override void Process(BehaviorState state) 22 | { 23 | if (!state.ExitedFarmhouse() && !state.HasCheckedForParsnipSeeds() && Game1.currentLocation is FarmHouse fh) 24 | { 25 | state.CheckForParsnipSeeds(); 26 | foreach (var kvp in fh.Objects.Pairs) 27 | { 28 | var obj = kvp.Value; 29 | if (obj is Chest chest) 30 | { 31 | if (chest.giftbox.Value) 32 | { 33 | chest.checkForAction(Game1.player); 34 | state.SetWaitTicks(60 * 2); 35 | break; 36 | } 37 | } 38 | } 39 | } else 40 | { 41 | processNext(state); 42 | } 43 | 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /DedicatedServer/HostAutomatorStages/EndCommunityCenterBehaviorLink.cs: -------------------------------------------------------------------------------- 1 | using StardewValley; 2 | 3 | namespace DedicatedServer.HostAutomatorStages 4 | { 5 | internal class EndCommunityCenterBehaviorLink : BehaviorLink 6 | { 7 | private bool isEnding; 8 | 9 | public EndCommunityCenterBehaviorLink(BehaviorLink next = null) : base(next) 10 | { 11 | isEnding = false; 12 | } 13 | 14 | public override void Process(BehaviorState state) 15 | { 16 | if (!Game1.player.eventsSeen.Contains(191393) && Game1.player.hasCompletedCommunityCenter() && !Game1.IsRainingHere(Game1.getLocationFromName("Town")) && !isEnding && !Utility.isFestivalDay(Game1.Date.DayOfMonth, Game1.Date.Season)) 17 | { 18 | Game1.warpFarmer("Town", 0, 54, 1); 19 | isEnding = true; 20 | } 21 | else if (isEnding && Game1.player.eventsSeen.Contains(191393)) { 22 | Game1.warpFarmer("Farm", 64, 10, 1); 23 | isEnding = false; 24 | } 25 | else 26 | { 27 | processNext(state); 28 | } 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /DedicatedServer/HostAutomatorStages/ExitFarmHouseBehaviorLink.cs: -------------------------------------------------------------------------------- 1 | using StardewValley; 2 | using StardewValley.Locations; 3 | using StardewValley.Menus; 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Linq; 7 | using System.Text; 8 | using System.Threading.Tasks; 9 | 10 | namespace DedicatedServer.HostAutomatorStages 11 | { 12 | internal class ExitFarmHouseBehaviorLink : BehaviorLink 13 | { 14 | public ExitFarmHouseBehaviorLink(BehaviorLink next = null) : base(next) 15 | { 16 | } 17 | 18 | public override void Process(BehaviorState state) 19 | { 20 | if (!state.ExitedFarmhouse() && Game1.currentLocation != null && Game1.currentLocation is FarmHouse) 21 | { 22 | var farm = Game1.getLocationFromName("Farm") as Farm; 23 | //Warping to 64, 10 warps just behind the farmhouse. It "hides" the bot, but still allows him to perform actions like talking to npcs. 24 | var warp = new Warp(64, 15, farm.NameOrUniqueName, 64, 10, false); // 64, 15 coords are "magic numbers" pulled from Game1.cs, line 11282, warpFarmer() 25 | Game1.player.warpFarmer(warp); 26 | state.ExitFarmhouse(); // Mark as exited 27 | state.SetWaitTicks(60); // Set up wait ticks to wait for possible event 28 | } 29 | else 30 | { 31 | processNext(state); 32 | } 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /DedicatedServer/HostAutomatorStages/FestivalChatBox.cs: -------------------------------------------------------------------------------- 1 | using DedicatedServer.Chat; 2 | using StardewValley; 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | using System.Text; 7 | using System.Threading.Tasks; 8 | 9 | namespace DedicatedServer.HostAutomatorStages 10 | { 11 | internal class FestivalChatBox 12 | { 13 | private const string entryMessage = "When you wish to start the festival, type \"start\" into chat. If you'd like to cancel your vote, type \"cancel\"."; 14 | 15 | private EventDrivenChatBox chatBox; 16 | private IDictionary otherPlayers; 17 | private bool enabled = false; 18 | private HashSet votes = new HashSet(); 19 | 20 | public FestivalChatBox(EventDrivenChatBox chatBox, IDictionary otherPlayers) 21 | { 22 | this.chatBox = chatBox; 23 | this.otherPlayers = otherPlayers; 24 | } 25 | 26 | public bool IsEnabled() 27 | { 28 | return enabled; 29 | } 30 | 31 | public void Enable() 32 | { 33 | if (!enabled) 34 | { 35 | enabled = true; 36 | votes.Clear(); 37 | chatBox.textBoxEnter(entryMessage); 38 | chatBox.ChatReceived += onChatReceived; 39 | } 40 | } 41 | 42 | public void Disable() 43 | { 44 | if (enabled) 45 | { 46 | enabled = false; 47 | votes.Clear(); 48 | chatBox.ChatReceived -= onChatReceived; 49 | } 50 | } 51 | 52 | private void onChatReceived(object sender, ChatEventArgs e) 53 | { 54 | if (!otherPlayers.ContainsKey(e.SourceFarmerId)) 55 | { 56 | return; 57 | } 58 | 59 | if (e.Message.ToLower() == "start") 60 | { 61 | votes.Add(e.SourceFarmerId); 62 | } 63 | else if (e.Message.ToLower() == "cancel") 64 | { 65 | votes.Remove(e.SourceFarmerId); 66 | } 67 | } 68 | 69 | public int NumVoted() 70 | { 71 | int count = 0; 72 | foreach (var id in otherPlayers.Keys) 73 | { 74 | if (votes.Contains(id)) 75 | { 76 | count++; 77 | } 78 | } 79 | return count; 80 | } 81 | 82 | public void SendChatMessage(string message) 83 | { 84 | chatBox.textBoxEnter(message); 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /DedicatedServer/HostAutomatorStages/GetFishingRodBehaviorLink.cs: -------------------------------------------------------------------------------- 1 | using StardewValley; 2 | 3 | namespace DedicatedServer.HostAutomatorStages 4 | { 5 | internal class GetFishingRodBehaviorLink : BehaviorLink 6 | { 7 | private bool isGettingFishingRod; 8 | 9 | public GetFishingRodBehaviorLink(BehaviorLink next = null) : base(next) 10 | { 11 | isGettingFishingRod = false; 12 | } 13 | 14 | public override void Process(BehaviorState state) 15 | { 16 | //If we don't get the fishing rod, Willy isn't available 17 | if (!Game1.player.eventsSeen.Contains(739330) && Game1.player.hasQuest(13) && Game1.timeOfDay <= 1710 && !isGettingFishingRod && !Utility.isFestivalDay(Game1.Date.DayOfMonth, Game1.Date.Season)) 18 | { 19 | Game1.warpFarmer("Beach", 38, 0, 1); 20 | isGettingFishingRod = true; 21 | } 22 | else if (isGettingFishingRod && Game1.player.eventsSeen.Contains(739330)) { 23 | Game1.warpFarmer("Farm", 64, 10, 1); 24 | isGettingFishingRod = false; 25 | } 26 | else 27 | { 28 | processNext(state); 29 | } 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /DedicatedServer/HostAutomatorStages/HostAutomatorStage.cs: -------------------------------------------------------------------------------- 1 | using StardewModdingAPI; 2 | using StardewModdingAPI.Events; 3 | using StardewValley; 4 | 5 | namespace DedicatedServer.HostAutomatorStages 6 | { 7 | internal abstract class HostAutomatorStage 8 | { 9 | protected IModHelper helper; 10 | 11 | public HostAutomatorStage(IModHelper helper) 12 | { 13 | this.helper = helper; 14 | } 15 | 16 | private void execute(object sender, UpdateTickedEventArgs e) 17 | { 18 | if (!Game1.netWorldState.Value.IsPaused) 19 | { 20 | Execute(sender, e); 21 | } 22 | } 23 | 24 | public void Enable() 25 | { 26 | helper.Events.GameLoop.UpdateTicked += execute; 27 | } 28 | 29 | public void Disable() 30 | { 31 | helper.Events.GameLoop.UpdateTicked -= execute; 32 | } 33 | 34 | public abstract void Execute(object sender, UpdateTickedEventArgs e); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /DedicatedServer/HostAutomatorStages/ProcessDialogueBehaviorLink.cs: -------------------------------------------------------------------------------- 1 | using DedicatedServer.Config; 2 | using StardewValley; 3 | using StardewValley.Menus; 4 | using System.Reflection; 5 | 6 | namespace DedicatedServer.HostAutomatorStages 7 | { 8 | internal class ProcessDialogueBehaviorLink : BehaviorLink 9 | { 10 | private static FieldInfo textBoxFieldInfo = typeof(NamingMenu).GetField("textBox", BindingFlags.NonPublic | BindingFlags.Instance); 11 | 12 | private ModConfig config; 13 | 14 | public ProcessDialogueBehaviorLink(ModConfig config, BehaviorLink next = null) : base(next) 15 | { 16 | this.config = config; 17 | } 18 | 19 | public override void Process(BehaviorState state) 20 | { 21 | if (Game1.activeClickableMenu != null) 22 | { 23 | if (Game1.activeClickableMenu is DialogueBox db) 24 | { 25 | if (state.HasBetweenDialoguesWaitTicks()) 26 | { 27 | state.DecrementBetweenDialoguesWaitTicks(); 28 | } 29 | else if (!db.isQuestion) 30 | { 31 | db.receiveLeftClick(0, 0); // Skip the non-question dialogue 32 | state.SkipDialogue(); 33 | } 34 | else 35 | { 36 | // For question dialogues, determine which question is being asked based on the 37 | // question / response text 38 | int mushroomsResponseIdx = -1; 39 | int batsResponseIdx = -1; 40 | int yesResponseIdx = -1; 41 | int noResponseIdx = -1; 42 | for (int i = 0; i < db.responses.Count; i++) 43 | { 44 | var response = db.responses[i]; 45 | var lowercaseText = response.responseText.ToLower(); 46 | if (lowercaseText == "mushrooms") 47 | { 48 | mushroomsResponseIdx = i; 49 | } 50 | else if (lowercaseText == "bats") 51 | { 52 | batsResponseIdx = i; 53 | } 54 | else if (lowercaseText == "yes") 55 | { 56 | yesResponseIdx = i; 57 | } 58 | else if (lowercaseText == "no") 59 | { 60 | noResponseIdx = i; 61 | } 62 | } 63 | 64 | db.selectedResponse = 0; 65 | if (mushroomsResponseIdx >= 0 && batsResponseIdx >= 0) 66 | { 67 | // This is the cave question. Answer based on mod config. 68 | if (config.MushroomsOrBats.ToLower() == "mushrooms") 69 | { 70 | db.selectedResponse = mushroomsResponseIdx; 71 | } 72 | else if (config.MushroomsOrBats.ToLower() == "bats") 73 | { 74 | db.selectedResponse = batsResponseIdx; 75 | } 76 | } 77 | else if (yesResponseIdx >= 0 && noResponseIdx >= 0) 78 | { 79 | // This is the pet question. Answer based on mod config. 80 | if (config.AcceptPet) 81 | { 82 | db.selectedResponse = yesResponseIdx; 83 | } 84 | else 85 | { 86 | db.selectedResponse = noResponseIdx; 87 | } 88 | } 89 | 90 | db.receiveLeftClick(0, 0); 91 | state.SkipDialogue(); 92 | } 93 | } 94 | else if (Game1.activeClickableMenu is NamingMenu nm) 95 | { 96 | if (state.HasBetweenDialoguesWaitTicks()) 97 | { 98 | state.DecrementBetweenDialoguesWaitTicks(); 99 | } 100 | else 101 | { 102 | TextBox textBox = (TextBox) textBoxFieldInfo.GetValue(nm); 103 | textBox.Text = config.PetName; 104 | textBox.RecieveCommandInput('\r'); 105 | state.SkipDialogue(); 106 | } 107 | } 108 | else if (Game1.activeClickableMenu is LevelUpMenu lum) 109 | { 110 | if (state.HasBetweenDialoguesWaitTicks()) 111 | { 112 | state.DecrementBetweenDialoguesWaitTicks(); 113 | } 114 | else 115 | { 116 | lum.okButtonClicked(); 117 | } 118 | } 119 | else 120 | { 121 | state.ClearBetweenDialoguesWaitTicks(); 122 | processNext(state); 123 | } 124 | } 125 | else 126 | { 127 | state.ClearBetweenDialoguesWaitTicks(); 128 | processNext(state); 129 | } 130 | } 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /DedicatedServer/HostAutomatorStages/ProcessFestivalChatBoxBehaviorLink.cs: -------------------------------------------------------------------------------- 1 | using StardewValley; 2 | using StardewValley.Locations; 3 | using StardewValley.Menus; 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Linq; 7 | using System.Reflection; 8 | using System.Text; 9 | using System.Threading.Tasks; 10 | 11 | namespace DedicatedServer.HostAutomatorStages 12 | { 13 | internal class ProcessFestivalChatBoxBehaviorLink : BehaviorLink 14 | { 15 | public ProcessFestivalChatBoxBehaviorLink(BehaviorLink next = null) : base(next) 16 | { 17 | } 18 | 19 | public override void Process(BehaviorState state) 20 | { 21 | Tuple voteCounts = state.UpdateFestivalStartVotes(); 22 | if (voteCounts != null) 23 | { 24 | if (voteCounts.Item1 == voteCounts.Item2) 25 | { 26 | // Start the festival 27 | state.SendChatMessage($"{voteCounts.Item1} / {voteCounts.Item2} votes casted. Starting the festival event..."); 28 | if (Game1.currentSeason == "summer" && Game1.dayOfMonth == 11 && Game1.player.team.luauIngredients.Count > 0) 29 | { 30 | // If it's the Luau and the pot isn't empty, add a duplicate of someone else's item to the pot. It (mostly) doesn't matter 31 | // which item is duplicated. Indeed, the total luau score is simply equal to the lowest score (or some extremum) of any item 32 | // added, with two exceptions: 1) if anyone adds the mayor's shorts, the score is set to a magic number (6) 33 | // and all other items added are ignored, and 2) if anyone doesn't add an item, the score is set to a magic number (5). 34 | // This means that having X players put in X items is no different from having X+1 players put in X+1 items, where the 35 | // additional item is a duplicate of one of the original X items. This is the intention. The only possible concern is that it 36 | // looks like putting in better items will improve relationships more. But it's probably not all that noticeable of a difference 37 | // anyways. So just duplicate the first element with Item.getOne(). 38 | Game1.player.team.luauIngredients.Add(Game1.player.team.luauIngredients[0].getOne()); 39 | } 40 | Game1.CurrentEvent.answerDialogueQuestion(null, "yes"); 41 | state.DisableFestivalChatBox(); 42 | } 43 | else 44 | { 45 | state.SendChatMessage($"{voteCounts.Item1} / {voteCounts.Item2} votes casted."); 46 | } 47 | } else 48 | { 49 | processNext(state); 50 | } 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /DedicatedServer/HostAutomatorStages/ProcessPauseBehaviorLink.cs: -------------------------------------------------------------------------------- 1 | using StardewModdingAPI; 2 | using StardewValley; 3 | using StardewValley.Locations; 4 | using StardewValley.Menus; 5 | using System; 6 | using System.Collections.Generic; 7 | using System.Linq; 8 | using System.Reflection; 9 | using System.Text; 10 | using System.Threading.Tasks; 11 | 12 | namespace DedicatedServer.HostAutomatorStages 13 | { 14 | internal class ProcessPauseBehaviorLink : BehaviorLink 15 | { 16 | private bool paused = false; 17 | 18 | public ProcessPauseBehaviorLink(BehaviorLink next = null) : base(next) 19 | { 20 | } 21 | 22 | public override void Process(BehaviorState state) 23 | { 24 | if (!Game1.netWorldState.Value.IsPaused) 25 | { 26 | paused = false; 27 | } 28 | 29 | if (state.GetNumOtherPlayers() == 0 && !Game1.isFestival() && !Game1.netWorldState.Value.IsPaused) 30 | { 31 | paused = true; 32 | Game1.netWorldState.Value.IsPaused = true; 33 | } 34 | else if ((state.GetNumOtherPlayers() > 0 && paused) || (Game1.isFestival() && Game1.netWorldState.Value.IsPaused)) 35 | { 36 | paused = false; 37 | Game1.netWorldState.Value.IsPaused = false; 38 | } 39 | else if (!Game1.netWorldState.Value.IsPaused) 40 | { 41 | processNext(state); 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /DedicatedServer/HostAutomatorStages/ProcessWaitTicksBehaviorLink.cs: -------------------------------------------------------------------------------- 1 | using StardewValley; 2 | using StardewValley.Menus; 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | using System.Text; 7 | using System.Threading.Tasks; 8 | 9 | namespace DedicatedServer.HostAutomatorStages 10 | { 11 | internal class ProcessWaitTicksBehaviorLink : BehaviorLink 12 | { 13 | public ProcessWaitTicksBehaviorLink(BehaviorLink next = null) : base(next) 14 | { 15 | } 16 | 17 | public override void Process(BehaviorState state) 18 | { 19 | if (state.HasWaitTicks()) 20 | { 21 | state.DecrementWaitTicks(); 22 | } else 23 | { 24 | processNext(state); 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /DedicatedServer/HostAutomatorStages/PurchaseJojaMembershipBehaviorLink.cs: -------------------------------------------------------------------------------- 1 | using DedicatedServer.Config; 2 | using StardewValley; 3 | 4 | namespace DedicatedServer.HostAutomatorStages 5 | { 6 | internal class PurchaseJojaMembershipBehaviorLink : BehaviorLink 7 | { 8 | private ModConfig config; 9 | 10 | public PurchaseJojaMembershipBehaviorLink(ModConfig config, BehaviorLink next = null) : base(next) { 11 | this.config = config; 12 | } 13 | 14 | public override void Process(BehaviorState state) 15 | { 16 | // If the community center has been unlocked, the config specifies that the host 17 | // should purchase the joja membership, and the host has not yet purchased it... 18 | var ccAvailable = Game1.player.eventsSeen.Contains(611439); 19 | var purchased = Game1.player.mailForTomorrow.Contains("JojaMember%&NL&%") || Game1.player.mailReceived.Contains("JojaMember"); 20 | if (ccAvailable && config.PurchaseJojaMembership && !purchased) 21 | { 22 | // Then purchase it 23 | Game1.addMailForTomorrow("JojaMember", noLetter: true, sendToEveryone: true); 24 | Game1.player.removeQuest(26); 25 | } 26 | else 27 | { 28 | processNext(state); 29 | } 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /DedicatedServer/HostAutomatorStages/ReadyCheckHelper.cs: -------------------------------------------------------------------------------- 1 | using Netcode; 2 | using StardewValley; 3 | using StardewValley.Locations; 4 | using StardewValley.Network; 5 | using System; 6 | using System.Collections.Generic; 7 | using System.Linq; 8 | using System.Reflection; 9 | 10 | namespace DedicatedServer.HostAutomatorStages 11 | { 12 | internal class ReadyCheckHelper 13 | { 14 | private static Assembly assembly = typeof(Game1).Assembly; 15 | private static Type readyCheckType = assembly.GetType("StardewValley.ReadyCheck"); 16 | private static Type netRefType = typeof(NetRef<>); 17 | private static Type readyCheckNetRefType = netRefType.MakeGenericType(readyCheckType); 18 | private static Type netStringDictionaryType = typeof(NetStringDictionary<,>); 19 | private static Type readyCheckDictionaryType = netStringDictionaryType.MakeGenericType(readyCheckType, readyCheckNetRefType); 20 | 21 | private static FieldInfo readyChecksFieldInfo = typeof(FarmerTeam).GetField("readyChecks", BindingFlags.NonPublic | BindingFlags.Instance); 22 | private static object readyChecks = null; 23 | 24 | private static MethodInfo readyChecksAddMethodInfo = readyCheckDictionaryType.GetMethod("Add", new Type[] { typeof(string), readyCheckType }); 25 | private static PropertyInfo readyChecksItemPropertyInfo = readyCheckDictionaryType.GetProperty("Item"); 26 | 27 | private static FieldInfo readyPlayersFieldInfo = readyCheckType.GetField("readyPlayers", BindingFlags.NonPublic | BindingFlags.Instance); 28 | 29 | private static Dictionary readyPlayersDictionary = new Dictionary(); 30 | 31 | public static void OnDayStarted(object sender, StardewModdingAPI.Events.DayStartedEventArgs e) 32 | { 33 | if (readyChecks == null) 34 | { 35 | readyChecks = readyChecksFieldInfo.GetValue(Game1.player.team); 36 | } 37 | 38 | //Checking mailbox sometimes gives some gold, but it's compulsory to unlock some events 39 | for (int i = 0; i < 10; ++i) { 40 | Game1.getFarm().mailbox(); 41 | } 42 | 43 | //Unlocks the sewer 44 | if (!Game1.player.eventsSeen.Contains(295672) && Game1.netWorldState.Value.MuseumPieces.Count() >= 60) { 45 | Game1.player.eventsSeen.Add(295672); 46 | } 47 | 48 | //Upgrade farmhouse to match highest level cabin 49 | var targetLevel = Game1.getFarm().buildings.Where(o => o.isCabin).Select(o => ((Cabin)o.indoors.Value).upgradeLevel).DefaultIfEmpty(0).Max(); 50 | if (targetLevel > Game1.player.HouseUpgradeLevel) { 51 | Game1.player.HouseUpgradeLevel = targetLevel; 52 | Game1.player.performRenovation("FarmHouse"); 53 | } 54 | 55 | 56 | Dictionary newReadyPlayersDictionary = new Dictionary(); 57 | foreach (var checkName in readyPlayersDictionary.Keys) 58 | { 59 | object readyCheck = null; 60 | try 61 | { 62 | readyCheck = Activator.CreateInstance(readyCheckType, new object[] { checkName }); 63 | readyChecksAddMethodInfo.Invoke(readyChecks, new object[] { checkName, readyCheck }); 64 | } 65 | catch (Exception) 66 | { 67 | readyCheck = readyChecksItemPropertyInfo.GetValue(readyChecks, new object[] { checkName }); 68 | } 69 | 70 | NetFarmerCollection readyPlayers = (NetFarmerCollection) readyPlayersFieldInfo.GetValue(readyCheck); 71 | newReadyPlayersDictionary.Add(checkName, readyPlayers); 72 | } 73 | readyPlayersDictionary = newReadyPlayersDictionary; 74 | } 75 | 76 | public static void WatchReadyCheck(string checkName) 77 | { 78 | readyPlayersDictionary.TryAdd(checkName, null); 79 | } 80 | 81 | // Prerequisite: OnDayStarted() must have been called at least once prior to this method being called. 82 | public static bool IsReady(string checkName, Farmer player) 83 | { 84 | if (readyPlayersDictionary.TryGetValue(checkName, out NetFarmerCollection readyPlayers) && readyPlayers != null) 85 | { 86 | return readyPlayers.Contains(player); 87 | } 88 | 89 | object readyCheck = null; 90 | try 91 | { 92 | readyCheck = Activator.CreateInstance(readyCheckType, new object[] { checkName }); 93 | readyChecksAddMethodInfo.Invoke(readyChecks, new object[] { checkName, readyCheck }); 94 | } 95 | catch (Exception) 96 | { 97 | readyCheck = readyChecksItemPropertyInfo.GetValue(readyChecks, new object[] { checkName }); 98 | } 99 | 100 | readyPlayers = (NetFarmerCollection) readyPlayersFieldInfo.GetValue(readyCheck); 101 | if (readyPlayersDictionary.ContainsKey(checkName)) 102 | { 103 | readyPlayersDictionary[checkName] = readyPlayers; 104 | } else 105 | { 106 | readyPlayersDictionary.Add(checkName , readyPlayers); 107 | } 108 | 109 | return readyPlayers.Contains(player); 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /DedicatedServer/HostAutomatorStages/SkipEventsBehaviorLink.cs: -------------------------------------------------------------------------------- 1 | using StardewValley; 2 | using StardewValley.Menus; 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | using System.Text; 7 | using System.Threading.Tasks; 8 | 9 | namespace DedicatedServer.HostAutomatorStages 10 | { 11 | internal class SkipEventsBehaviorLink : BehaviorLink 12 | { 13 | public SkipEventsBehaviorLink(BehaviorLink next = null) : base(next) 14 | { 15 | } 16 | 17 | public override void Process(BehaviorState state) 18 | { 19 | if (Game1.CurrentEvent != null && Game1.CurrentEvent.skippable) 20 | { 21 | if (state.HasBetweenEventsWaitTicks()) 22 | { 23 | state.DecrementBetweenEventsWaitTicks(); 24 | } 25 | else 26 | { 27 | Game1.CurrentEvent.skipEvent(); 28 | state.SkipEvent(); // Set up wait ticks to wait before trying to skip event again, and wait to anticipate another following event 29 | } 30 | } else 31 | { 32 | state.ClearBetweenEventsWaitTicks(); 33 | processNext(state); 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /DedicatedServer/HostAutomatorStages/SkipShippingMenuBehaviorLink.cs: -------------------------------------------------------------------------------- 1 | using StardewValley; 2 | using StardewValley.Menus; 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | using System.Reflection; 7 | using System.Text; 8 | using System.Threading.Tasks; 9 | 10 | namespace DedicatedServer.HostAutomatorStages 11 | { 12 | internal class SkipShippingMenuBehaviorLink : BehaviorLink 13 | { 14 | private static MethodInfo info = typeof(ShippingMenu).GetMethod("okClicked", BindingFlags.Instance | BindingFlags.NonPublic); 15 | 16 | public SkipShippingMenuBehaviorLink(BehaviorLink next = null) : base(next) 17 | { 18 | } 19 | 20 | public override void Process(BehaviorState state) 21 | { 22 | if (Game1.activeClickableMenu is ShippingMenu sm) 23 | { 24 | if (state.HasBetweenShippingMenusWaitTicks()) 25 | { 26 | state.DecrementBetweenShippingMenusWaitTicks(); 27 | } else 28 | { 29 | info.Invoke(sm, new object[]{}); 30 | state.SkipShippingMenu(); 31 | } 32 | } else 33 | { 34 | state.ClearBetweenShippingMenusWaitTicks(); 35 | processNext(state); 36 | } 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /DedicatedServer/HostAutomatorStages/StartFarmStage.cs: -------------------------------------------------------------------------------- 1 | using DedicatedServer.Chat; 2 | using DedicatedServer.Config; 3 | using DedicatedServer.Crops; 4 | using DedicatedServer.MessageCommands; 5 | using StardewModdingAPI; 6 | using StardewModdingAPI.Events; 7 | using StardewValley; 8 | using StardewValley.Menus; 9 | using System; 10 | using System.Collections.Generic; 11 | using System.Reflection; 12 | 13 | // TODO move config value checking to the ModEntry, or another dedicated class, to be performed 14 | // prior to any updates / Execute() calls. Also make sure to check validity of newly added fields, like 15 | // the cave type selection and the PetName 16 | 17 | namespace DedicatedServer.HostAutomatorStages 18 | { 19 | internal class StartFarmStage : HostAutomatorStage 20 | { 21 | private IMonitor monitor; 22 | private ModConfig config; 23 | private CropSaver cropSaver = null; 24 | private AutomatedHost automatedHost = null; 25 | private BuildCommandListener buildCommandListener = null; 26 | private DemolishCommandListener demolishCommandListener = null; 27 | private PauseCommandListener pauseCommandListener = null; 28 | private ServerCommandListener serverCommandListener = null; 29 | 30 | public StartFarmStage(IModHelper helper, IMonitor monitor, ModConfig config) : base(helper) 31 | { 32 | this.monitor = monitor; 33 | this.config = config; 34 | helper.Events.GameLoop.SaveLoaded += onSaveLoaded; 35 | if (config.EnableCropSaver) { 36 | cropSaver = new CropSaver(helper, monitor, config); 37 | cropSaver.Enable(); 38 | } 39 | helper.Events.GameLoop.DayStarted += ReadyCheckHelper.OnDayStarted; 40 | helper.Events.GameLoop.ReturnedToTitle += onReturnToTitle; 41 | } 42 | 43 | private void logConfigError(string error) 44 | { 45 | monitor.Log($"Error in DedicatedServer mod config file. {error}", LogLevel.Error); 46 | } 47 | 48 | private void exit(int statusCode) 49 | { 50 | monitor.Log("Exiting...", LogLevel.Error); 51 | Environment.Exit(statusCode); 52 | } 53 | 54 | public override void Execute(object sender, UpdateTickedEventArgs e) 55 | { 56 | if (Game1.activeClickableMenu is not TitleMenu menu) 57 | { 58 | return; 59 | } 60 | 61 | MethodInfo info = typeof(LoadGameMenu).GetMethod("FindSaveGames", BindingFlags.Static | BindingFlags.NonPublic); 62 | object result = info.Invoke(obj: null, parameters: Array.Empty()); 63 | List farmers = result as List; 64 | if (farmers == null) 65 | { 66 | return; 67 | } 68 | 69 | Farmer hostedFarmer = null; 70 | foreach (Farmer farmer in farmers) 71 | { 72 | if (!farmer.slotCanHost) 73 | { 74 | continue; 75 | } 76 | if (farmer.farmName.Value == config.FarmName) 77 | { 78 | hostedFarmer = farmer; 79 | break; 80 | } 81 | } 82 | 83 | if (hostedFarmer != null) 84 | { 85 | monitor.Log($"Hosting {hostedFarmer.slotName} on co-op", LogLevel.Debug); 86 | 87 | // Mechanisms pulled from CoopMenu.HostFileSlot 88 | Game1.multiplayerMode = 2; 89 | SaveGame.Load(hostedFarmer.slotName); 90 | Game1.exitActiveMenu(); 91 | } 92 | else 93 | { 94 | monitor.Log($"Failed to find farm slot. Creating new farm \"{config.FarmName}\" and hosting on co-op", LogLevel.Debug); 95 | // Mechanism pulled from CoopMenu.HostNewFarmSlot; CharacterCustomization class; and AdvancedGameOptions class 96 | Game1.resetPlayer(); 97 | 98 | // Starting cabins 99 | if (config.StartingCabins < 0 || config.StartingCabins > 3) 100 | { 101 | logConfigError("Starting cabins must be an integer in [0, 3]"); 102 | exit(-1); 103 | } 104 | Game1.startingCabins = config.StartingCabins; 105 | 106 | // Cabin layout 107 | if (config.CabinLayout != "nearby" && config.CabinLayout != "separate") 108 | { 109 | logConfigError("Cabin layout must be either \"nearby\" or \"separate\""); 110 | exit(-1); 111 | } 112 | if (config.CabinLayout == "separate") 113 | { 114 | Game1.cabinsSeparate = true; 115 | } 116 | else 117 | { 118 | Game1.cabinsSeparate = false; 119 | } 120 | 121 | // Profit margin 122 | if (config.ProfitMargin != "normal" && config.ProfitMargin != "75%" && config.ProfitMargin != "50%" && config.ProfitMargin != "25%") 123 | { 124 | logConfigError("Profit margin must be one of \"normal\", \"75%\", \"50%\", or \"25%\""); 125 | exit(-1); 126 | } 127 | if (config.ProfitMargin == "normal") 128 | { 129 | Game1.player.difficultyModifier = 1f; 130 | } 131 | else if (config.ProfitMargin == "75%") 132 | { 133 | Game1.player.difficultyModifier = 0.75f; 134 | } 135 | else if (config.ProfitMargin == "50%") 136 | { 137 | Game1.player.difficultyModifier = 0.5f; 138 | } 139 | else 140 | { 141 | Game1.player.difficultyModifier = 0.25f; 142 | } 143 | 144 | // Money style 145 | if (config.MoneyStyle != "shared" && config.MoneyStyle != "separate") 146 | { 147 | logConfigError("Money style must be either \"shared\" or \"separate\""); 148 | exit(-1); 149 | } 150 | if (config.MoneyStyle == "separate") 151 | { 152 | Game1.player.team.useSeparateWallets.Value = true; 153 | } 154 | else 155 | { 156 | Game1.player.team.useSeparateWallets.Value = false; 157 | } 158 | 159 | // Farm name 160 | Game1.player.farmName.Value = config.FarmName; 161 | 162 | // Pet species 163 | if (config.PetSpecies != null && config.PetSpecies != "dog" && config.PetSpecies != "cat") 164 | { 165 | logConfigError("PetSpecies must be either \"dog\" or \"cat\""); 166 | exit(-1); 167 | } 168 | if (config.AcceptPet && config.PetSpecies == null) 169 | { 170 | logConfigError("PetSpecies must be specified if AcceptPet is true"); 171 | } 172 | if (config.PetSpecies == "cat") 173 | { 174 | Game1.player.catPerson = true; 175 | } 176 | else 177 | { 178 | Game1.player.catPerson = false; 179 | } 180 | 181 | // Pet breed 182 | if (config.PetBreed.HasValue && (config.PetBreed < 0 || config.PetBreed > 2)) 183 | { 184 | logConfigError("PetBreed must be an integer in [0, 2]"); 185 | exit(-1); 186 | } 187 | if (config.AcceptPet && !config.PetBreed.HasValue) 188 | { 189 | logConfigError("PetBreed must be specified if AcceptPet is true"); 190 | } 191 | if (config.PetBreed.HasValue) 192 | { 193 | Game1.player.whichPetBreed = config.PetBreed.Value; 194 | } else 195 | { 196 | Game1.player.whichPetBreed = 0; 197 | } 198 | 199 | // Farm type 200 | if (config.FarmType != "standard" && config.FarmType != "riverland" && config.FarmType != "forest" && config.FarmType != "hilltop" && config.FarmType != "wilderness" && config.FarmType != "fourcorners" && config.FarmType != "beach") 201 | { 202 | logConfigError("Farm type must be one of \"standard\", \"riverland\", \"forest\", \"hilltop\", \"wilderness\", \"fourcorners\", or \"beach\""); 203 | exit(-1); 204 | } 205 | if (config.FarmType == "standard") 206 | { 207 | Game1.whichFarm = 0; 208 | } 209 | else if (config.FarmType == "riverland") 210 | { 211 | Game1.whichFarm = 1; 212 | } 213 | else if (config.FarmType == "forest") 214 | { 215 | Game1.whichFarm = 2; 216 | } 217 | else if (config.FarmType == "hilltop") 218 | { 219 | Game1.whichFarm = 3; 220 | } 221 | else if (config.FarmType == "wilderness") 222 | { 223 | Game1.whichFarm = 4; 224 | } 225 | else if (config.FarmType == "fourcorners") 226 | { 227 | Game1.whichFarm = 5; 228 | } 229 | else if (config.FarmType == "beach") 230 | { 231 | Game1.whichFarm = 6; 232 | } 233 | 234 | // Community center bundles type 235 | if (config.CommunityCenterBundles != "normal" && config.CommunityCenterBundles != "remixed") 236 | { 237 | logConfigError("Community center bundles must be either \"normal\" or \"remixed\""); 238 | exit(-1); 239 | } 240 | if (config.CommunityCenterBundles == "normal") 241 | { 242 | Game1.bundleType = Game1.BundleType.Default; 243 | } 244 | else 245 | { 246 | Game1.bundleType = Game1.BundleType.Remixed; 247 | } 248 | 249 | // Guarantee year 1 completable flag 250 | Game1.game1.SetNewGameOption("YearOneCompletable", config.GuaranteeYear1Completable); 251 | 252 | // Mine rewards type 253 | if (config.MineRewards != "normal" && config.MineRewards != "remixed") 254 | { 255 | logConfigError("Mine rewards must be either \"normal\" or \"remixed\""); 256 | exit(-1); 257 | } 258 | if (config.MineRewards == "normal") 259 | { 260 | Game1.game1.SetNewGameOption("MineChests", Game1.MineChestType.Default); 261 | } 262 | else 263 | { 264 | Game1.game1.SetNewGameOption("MineChests", Game1.MineChestType.Remixed); 265 | } 266 | 267 | // Monsters spawning at night on farm 268 | Game1.spawnMonstersAtNight = config.SpawnMonstersOnFarmAtNight; 269 | Game1.game1.SetNewGameOption("SpawnMonstersAtNight", config.SpawnMonstersOnFarmAtNight); 270 | 271 | // Random seed 272 | Game1.startingGameSeed = config.RandomSeed; 273 | 274 | // Configuration is done; Set server bot constants 275 | Game1.player.Name = "ServerBot"; 276 | Game1.player.displayName = Game1.player.Name; 277 | Game1.player.favoriteThing.Value = "Farms"; 278 | Game1.player.isCustomized.Value = true; 279 | Game1.multiplayerMode = 2; 280 | 281 | // Start game 282 | menu.createdNewCharacter(true); 283 | } 284 | 285 | Disable(); 286 | } 287 | 288 | private void onSaveLoaded(object sender, SaveLoadedEventArgs e) 289 | { 290 | Game1.onScreenMenus.Remove(Game1.chatBox); 291 | var chatBox = new EventDrivenChatBox(); 292 | Game1.chatBox = chatBox; 293 | Game1.onScreenMenus.Add(chatBox); 294 | // Update the player limits (remove them) 295 | // This breaks the game since there are loops which iterate in the range 296 | // (1, ..., HighestPlayerLimit). I think the only loops regarding this 297 | // value are around loading / creating cellar maps on world load... 298 | // maybe we just have to sacrifice cellar-per-player. Or maybe we have to 299 | // update the value dynamically, and load new cellars whenever a new player 300 | // joins? Unclear... 301 | //Game1.netWorldState.Value.HighestPlayerLimit.Value = int.MaxValue; 302 | Game1.netWorldState.Value.CurrentPlayerLimit.Value = int.MaxValue; 303 | // NOTE: It will be very difficult, if not impossible, to remove the 304 | // cabin-per-player requirement. This requirement is very much built in 305 | // to much of the multiplayer networking connect / disconnect logic, and, 306 | // more importantly, every cabin has a SINGLE "farmhand" assigned to it. 307 | // Indeed, it's a 1-to-1 relationship---multiple farmers can't be assigned 308 | // to the same cabin. And this is a property of the cabin interface, so 309 | // it can't even be extended / modified. The most viable way to remove the 310 | // cabin-per-player requirement would be to create "invisible cabins" 311 | // which all sit on top of the farmhouse (for instance). They'd have 312 | // to be invisible (so that only the farmhouse is rendered), and 313 | // somehow they'd have to be made so that you can't collide with them 314 | // (though maybe this could be solved naturally by placing it to overlap 315 | // with the farmhouse in just the right position). Whenever a player enters 316 | // one of these cabins automatically (e.g., by warping home after passing out), 317 | // they'd have to be warped out of it immediately back into the farmhouse, since 318 | // these cabins should NOT be enterable in general (this part might be impossible 319 | // to do seamlessly, but it could theoretically be done in some manner). The mailbox 320 | // for the farmhouse would have to somehow be used instead of the cabin's mailbox (this 321 | // part might be totally impossible). And there would always have to be at least one 322 | // unclaimed invisible cabin at all times (every time one is claimed by a joining player, 323 | // create a new one). This would require a lot of work, and the mailbox part might 324 | // be totally impossible. 325 | 326 | // The command movebuildpermission is a standard command. 327 | // The server must be started, the value is set accordingly after each start 328 | chatBox.textBoxEnter("/mbp " + config.MoveBuildPermission); 329 | 330 | //We set bot mining lvl to 10 so he doesn't lvlup passively 331 | Game1.player.MiningLevel = 10; 332 | 333 | automatedHost = new AutomatedHost(helper, monitor, config, chatBox); 334 | automatedHost.Enable(); 335 | 336 | buildCommandListener = new BuildCommandListener(chatBox); 337 | buildCommandListener.Enable(); 338 | demolishCommandListener = new DemolishCommandListener(chatBox); 339 | demolishCommandListener.Enable(); 340 | pauseCommandListener = new PauseCommandListener(chatBox); 341 | pauseCommandListener.Enable(); 342 | serverCommandListener = new ServerCommandListener(helper, config, chatBox); 343 | serverCommandListener.Enable(); 344 | } 345 | 346 | private void onReturnToTitle(object sender, ReturnedToTitleEventArgs e) 347 | { 348 | automatedHost?.Disable(); 349 | automatedHost = null; 350 | buildCommandListener?.Disable(); 351 | buildCommandListener = null; 352 | demolishCommandListener?.Disable(); 353 | demolishCommandListener = null; 354 | pauseCommandListener?.Disable(); 355 | pauseCommandListener = null; 356 | serverCommandListener?.Disable(); 357 | serverCommandListener = null; 358 | } 359 | } 360 | } 361 | -------------------------------------------------------------------------------- /DedicatedServer/HostAutomatorStages/TransitionFestivalAttendanceBehaviorLink.cs: -------------------------------------------------------------------------------- 1 | using StardewValley; 2 | using StardewValley.Locations; 3 | using StardewValley.Menus; 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Linq; 7 | using System.Reflection; 8 | using System.Text; 9 | using System.Threading.Tasks; 10 | 11 | namespace DedicatedServer.HostAutomatorStages 12 | { 13 | internal class TransitionFestivalAttendanceBehaviorLink : BehaviorLink 14 | { 15 | private static MethodInfo info = typeof(Game1).GetMethod("performWarpFarmer", BindingFlags.Static | BindingFlags.NonPublic); 16 | public TransitionFestivalAttendanceBehaviorLink(BehaviorLink next = null) : base(next) 17 | { 18 | } 19 | 20 | private static string getLocationOfFestival() 21 | { 22 | if (Game1.weatherIcon == 1) 23 | { 24 | return Game1.temporaryContent.Load>("Data\\Festivals\\" + Game1.currentSeason + Game1.dayOfMonth)["conditions"].Split('/')[0]; 25 | } 26 | 27 | return null; 28 | } 29 | 30 | public override void Process(BehaviorState state) 31 | { 32 | if (Utils.Festivals.ShouldAttend(state.GetNumOtherPlayers()) && !Utils.Festivals.IsWaitingToAttend()) 33 | { 34 | if (state.HasBetweenTransitionFestivalAttendanceWaitTicks()) 35 | { 36 | state.DecrementBetweenTransitionFestivalAttendanceWaitTicks(); 37 | } else 38 | { 39 | var location = Game1.getLocationFromName(getLocationOfFestival()); 40 | var warp = new Warp(0, 0, location.NameOrUniqueName, 0, 0, false); 41 | Game1.player.team.SetLocalReady("festivalStart", ready: true); 42 | Game1.activeClickableMenu = new ReadyCheckDialog("festivalStart", allowCancel: true, delegate (Farmer who) 43 | { 44 | Game1.exitActiveMenu(); 45 | info.Invoke(null, new object[] { Game1.getLocationRequest(warp.TargetName), 0, 0, Game1.player.facingDirection.Value }); 46 | if ((Game1.currentSeason != "fall" || Game1.dayOfMonth != 27) && (Game1.currentSeason != "winter" || Game1.dayOfMonth != 25)) // Don't enable chat box on spirit's eve nor feast of the winter star 47 | { 48 | state.EnableFestivalChatBox(); 49 | } 50 | }); 51 | state.WaitForFestivalAttendance(); 52 | } 53 | } else if (!Utils.Festivals.ShouldAttend(state.GetNumOtherPlayers()) && Utils.Festivals.IsWaitingToAttend()) 54 | { 55 | if (state.HasBetweenTransitionFestivalAttendanceWaitTicks()) 56 | { 57 | state.DecrementBetweenTransitionFestivalAttendanceWaitTicks(); 58 | } else 59 | { 60 | if (Game1.activeClickableMenu != null && Game1.activeClickableMenu is ReadyCheckDialog rcd) 61 | { 62 | rcd.closeDialog(Game1.player); 63 | } 64 | Game1.player.team.SetLocalReady("festivalStart", false); 65 | state.StopWaitingForFestivalAttendance(); 66 | } 67 | } 68 | else 69 | { 70 | state.ClearBetweenTransitionFestivalAttendanceWaitTicks(); 71 | processNext(state); 72 | } 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /DedicatedServer/HostAutomatorStages/TransitionFestivalEndBehaviorLink.cs: -------------------------------------------------------------------------------- 1 | using StardewValley; 2 | using StardewValley.Locations; 3 | using StardewValley.Menus; 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Linq; 7 | using System.Reflection; 8 | using System.Text; 9 | using System.Threading.Tasks; 10 | 11 | namespace DedicatedServer.HostAutomatorStages 12 | { 13 | internal class TransitionFestivalEndBehaviorLink : BehaviorLink 14 | { 15 | public TransitionFestivalEndBehaviorLink(BehaviorLink next = null) : base(next) 16 | { 17 | } 18 | 19 | public override void Process(BehaviorState state) 20 | { 21 | if (Utils.Festivals.ShouldLeave(state.GetNumOtherPlayers()) && !Utils.Festivals.IsWaitingToLeave()) 22 | { 23 | if (state.HasBetweenTransitionFestivalEndWaitTicks()) 24 | { 25 | state.DecrementBetweenTransitionFestivalEndWaitTicks(); 26 | } else 27 | { 28 | Game1.player.team.SetLocalReady("festivalEnd", ready: true); 29 | Game1.activeClickableMenu = new ReadyCheckDialog("festivalEnd", allowCancel: true, delegate (Farmer who) 30 | { 31 | Game1.currentLocation.currentEvent.forceEndFestival(who); 32 | state.DisableFestivalChatBox(); 33 | }); 34 | state.WaitForFestivalEnd(); 35 | } 36 | } else if (!Utils.Festivals.ShouldLeave(state.GetNumOtherPlayers()) && Utils.Festivals.IsWaitingToLeave()) 37 | { 38 | if (state.HasBetweenTransitionFestivalEndWaitTicks()) 39 | { 40 | state.DecrementBetweenTransitionFestivalEndWaitTicks(); 41 | } else 42 | { 43 | if (Game1.activeClickableMenu != null && Game1.activeClickableMenu is ReadyCheckDialog rcd) 44 | { 45 | rcd.closeDialog(Game1.player); 46 | } 47 | Game1.player.team.SetLocalReady("festivalEnd", false); 48 | state.StopWaitingForFestivalEnd(); 49 | } 50 | } 51 | else 52 | { 53 | state.ClearBetweenTransitionFestivalEndWaitTicks(); 54 | processNext(state); 55 | } 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /DedicatedServer/HostAutomatorStages/TransitionSleepBehaviorLink.cs: -------------------------------------------------------------------------------- 1 | using StardewValley; 2 | using StardewValley.Locations; 3 | using StardewValley.Menus; 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Linq; 7 | using System.Reflection; 8 | using System.Text; 9 | using System.Threading.Tasks; 10 | 11 | namespace DedicatedServer.HostAutomatorStages 12 | { 13 | internal class TransitionSleepBehaviorLink : BehaviorLink 14 | { 15 | private static MethodInfo info = typeof(GameLocation).GetMethod("doSleep", BindingFlags.Instance | BindingFlags.NonPublic); 16 | 17 | public TransitionSleepBehaviorLink(BehaviorLink next = null) : base(next) 18 | { 19 | } 20 | 21 | public override void Process(BehaviorState state) 22 | { 23 | if (Utils.Sleeping.ShouldSleep(state.GetNumOtherPlayers()) && !Utils.Sleeping.IsSleeping()) 24 | { 25 | if (state.HasBetweenTransitionSleepWaitTicks()) 26 | { 27 | state.DecrementBetweenTransitionSleepWaitTicks(); 28 | } 29 | else if (Game1.currentLocation is FarmHouse) 30 | { 31 | Game1.player.isInBed.Value = true; 32 | Game1.player.sleptInTemporaryBed.Value = true; 33 | Game1.player.timeWentToBed.Value = Game1.timeOfDay; 34 | Game1.player.team.SetLocalReady("sleep", ready: true); 35 | Game1.dialogueUp = false; 36 | Game1.activeClickableMenu = new ReadyCheckDialog("sleep", allowCancel: true, delegate 37 | { 38 | Game1.player.isInBed.Value = true; 39 | Game1.player.sleptInTemporaryBed.Value = true; 40 | info.Invoke(Game1.currentLocation, new object[]{}); 41 | }, delegate (Farmer who) 42 | { 43 | if (Game1.activeClickableMenu != null && Game1.activeClickableMenu is ReadyCheckDialog rcd) 44 | { 45 | rcd.closeDialog(who); 46 | } 47 | 48 | who.timeWentToBed.Value = 0; 49 | }); 50 | 51 | if (!Game1.player.team.announcedSleepingFarmers.Contains(Game1.player)) 52 | Game1.player.team.announcedSleepingFarmers.Add(Game1.player); 53 | 54 | state.Sleep(); 55 | } 56 | else 57 | { 58 | var farmHouse = Game1.getLocationFromName("FarmHouse") as FarmHouse; 59 | var entryLocation = farmHouse.getEntryLocation(); 60 | var warp = new Warp(entryLocation.X, entryLocation.Y, farmHouse.NameOrUniqueName, entryLocation.X, entryLocation.Y, false); 61 | Game1.player.warpFarmer(warp); 62 | state.WarpToSleep(); 63 | } 64 | } else if (!Utils.Sleeping.ShouldSleep(state.GetNumOtherPlayers()) && Utils.Sleeping.IsSleeping()) 65 | { 66 | if (state.HasBetweenTransitionSleepWaitTicks()) 67 | { 68 | state.DecrementBetweenTransitionSleepWaitTicks(); 69 | } 70 | else 71 | { 72 | if (Game1.activeClickableMenu != null && Game1.activeClickableMenu is ReadyCheckDialog rcd) 73 | { 74 | rcd.closeDialog(Game1.player); 75 | } 76 | Game1.player.team.SetLocalReady("sleep", false); 77 | state.CancelSleep(); 78 | } 79 | } 80 | else 81 | { 82 | state.ClearBetweenTransitionSleepWaitTicks(); 83 | processNext(state); 84 | } 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /DedicatedServer/HostAutomatorStages/UnlockCommunityCenterBehaviorLink.cs: -------------------------------------------------------------------------------- 1 | using StardewValley; 2 | 3 | namespace DedicatedServer.HostAutomatorStages 4 | { 5 | internal class UnlockCommunityCenterBehaviorLink : BehaviorLink 6 | { 7 | private bool isUnlocking; 8 | 9 | public UnlockCommunityCenterBehaviorLink(BehaviorLink next = null) : base(next) 10 | { 11 | isUnlocking = false; 12 | } 13 | 14 | public override void Process(BehaviorState state) 15 | { 16 | if (!Game1.player.eventsSeen.Contains(611439) && Game1.stats.daysPlayed > 4 && Game1.timeOfDay >= 800 && Game1.timeOfDay <= 1300 && !Game1.IsRainingHere(Game1.getLocationFromName("Town")) && !isUnlocking && !Utility.isFestivalDay(Game1.Date.DayOfMonth, Game1.Date.Season)) 17 | { 18 | Game1.warpFarmer("Town", 0, 54, 1); 19 | isUnlocking = true; 20 | } 21 | else if (isUnlocking && Game1.player.eventsSeen.Contains(611439)) { 22 | Game1.warpFarmer("Farm", 64, 10, 1); 23 | isUnlocking = false; 24 | } 25 | else 26 | { 27 | processNext(state); 28 | } 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /DedicatedServer/HostAutomatorStages/UpdateStateBehaviorLink.cs: -------------------------------------------------------------------------------- 1 | using StardewModdingAPI; 2 | using StardewValley; 3 | using StardewValley.Locations; 4 | using StardewValley.Menus; 5 | using System; 6 | using System.Collections.Generic; 7 | using System.Linq; 8 | using System.Reflection; 9 | using System.Text; 10 | using System.Threading.Tasks; 11 | 12 | namespace DedicatedServer.HostAutomatorStages 13 | { 14 | internal class UpdateStateBehaviorLink : BehaviorLink 15 | { 16 | public UpdateStateBehaviorLink(BehaviorLink next = null) : base(next) 17 | { 18 | } 19 | 20 | public override void Process(BehaviorState state) 21 | { 22 | state.UpdateOtherPlayers(); 23 | processNext(state); 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /DedicatedServer/MessageCommands/BuildCommandListener.cs: -------------------------------------------------------------------------------- 1 | using DedicatedServer.Chat; 2 | using Microsoft.Xna.Framework; 3 | using StardewValley; 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Linq; 7 | using System.Text; 8 | using System.Threading.Tasks; 9 | 10 | namespace DedicatedServer.MessageCommands 11 | { 12 | internal class BuildCommandListener 13 | { 14 | private static Dictionary> buildingActions = new Dictionary> 15 | { 16 | {"stone_cabin", genBuildCabin("Stone Cabin")}, 17 | {"plank_cabin", genBuildCabin("Plank Cabin")}, 18 | {"log_cabin", genBuildCabin("Log Cabin")}, 19 | }; 20 | private static readonly string validBuildingNamesList = genValidBuildingNamesList(); 21 | 22 | private EventDrivenChatBox chatBox; 23 | 24 | public BuildCommandListener(EventDrivenChatBox chatBox) 25 | { 26 | this.chatBox = chatBox; 27 | } 28 | 29 | private static Action genBuildCabin(string cabinBlueprintName) 30 | { 31 | void buildCabin(EventDrivenChatBox chatBox, Farmer farmer) 32 | { 33 | var point = farmer.getTileLocation(); 34 | var blueprint = new BluePrint(cabinBlueprintName); 35 | switch (farmer.facingDirection.Value) 36 | { 37 | case 1: // Right 38 | point.X++; 39 | point.Y -= (blueprint.tilesHeight / 2); 40 | break; 41 | case 2: // Down 42 | point.X -= (blueprint.tilesWidth / 2); 43 | point.Y++; 44 | break; 45 | case 3: // Left 46 | point.X -= blueprint.tilesWidth; 47 | point.Y -= (blueprint.tilesHeight / 2); 48 | break; 49 | default: // 0 = Up 50 | point.X -= (blueprint.tilesWidth / 2); 51 | point.Y -= blueprint.tilesHeight; 52 | break; 53 | } 54 | Game1.player.team.buildLock.RequestLock(delegate 55 | { 56 | if (Game1.locationRequest == null) 57 | { 58 | var res = ((Farm)Game1.getLocationFromName("Farm")).buildStructure(blueprint, new Vector2(point.X, point.Y), Game1.player, false); 59 | if (res) 60 | { 61 | chatBox.textBoxEnter(farmer.Name + " just built a " + cabinBlueprintName); 62 | } 63 | else 64 | { 65 | chatBox.textBoxEnter("/message " + farmer.Name + " Error: " + Game1.content.LoadString("Strings\\UI:Carpenter_CantBuild")); 66 | } 67 | } 68 | Game1.player.team.buildLock.ReleaseLock(); 69 | }); 70 | } 71 | return buildCabin; 72 | } 73 | 74 | private static string genValidBuildingNamesList() 75 | { 76 | string str = ""; 77 | var buildingActionsEnumerable = buildingActions.Keys.ToArray(); 78 | for (int i = 0; i < buildingActionsEnumerable.Length; i++) 79 | { 80 | str += "\"" + buildingActionsEnumerable[i] + "\""; 81 | if (i + 1 < buildingActionsEnumerable.Length) 82 | { 83 | str += ", "; 84 | } 85 | if (i + 1 == buildingActionsEnumerable.Length - 1) 86 | { 87 | str += "and "; 88 | } 89 | } 90 | return str; 91 | } 92 | 93 | private void pmValidBuildingNames(Farmer farmer) 94 | { 95 | var str = "/message " + farmer.Name + " Valid building names include " + validBuildingNamesList; 96 | chatBox.textBoxEnter(str); 97 | } 98 | 99 | public void Enable() 100 | { 101 | chatBox.ChatReceived += chatReceived; 102 | } 103 | 104 | public void Disable() 105 | { 106 | chatBox.ChatReceived -= chatReceived; 107 | } 108 | 109 | private void chatReceived(object sender, ChatEventArgs e) 110 | { 111 | // Private message chatKind is 3 112 | var tokens = e.Message.ToLower().Split(' '); 113 | if (tokens.Length == 0) 114 | { 115 | return; 116 | } 117 | if (e.ChatKind == 3 && tokens[0] == "build") 118 | { 119 | // Find the farmer it came from and determine their location 120 | foreach (var farmer in Game1.otherFarmers.Values) 121 | { 122 | if (farmer.UniqueMultiplayerID == e.SourceFarmerId) 123 | { 124 | if (tokens.Length != 2) 125 | { 126 | chatBox.textBoxEnter("/message " + farmer.Name + " Error: Invalid command usage."); 127 | chatBox.textBoxEnter("/message " + farmer.Name + " Usage: build [building_name]"); 128 | pmValidBuildingNames(farmer); 129 | return; 130 | } 131 | var buildingName = tokens[1]; 132 | if (buildingActions.TryGetValue(buildingName, out var action)) 133 | { 134 | var location = farmer.currentLocation; 135 | if (location is Farm f) 136 | { 137 | action(chatBox, farmer); 138 | } 139 | else 140 | { 141 | chatBox.textBoxEnter("/message " + farmer.Name + " Error: You cannot place buildings outside of the farm!"); 142 | } 143 | } 144 | else 145 | { 146 | chatBox.textBoxEnter("/message " + farmer.Name + " Error: Unrecognized building name \"" + buildingName + "\""); 147 | pmValidBuildingNames(farmer); 148 | } 149 | break; 150 | } 151 | } 152 | } 153 | } 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /DedicatedServer/MessageCommands/DemolishCommandListener.cs: -------------------------------------------------------------------------------- 1 | using DedicatedServer.Chat; 2 | using Microsoft.Xna.Framework; 3 | using StardewValley; 4 | using StardewValley.Buildings; 5 | using StardewValley.Locations; 6 | using StardewValley.Objects; 7 | using System; 8 | using System.Collections.Generic; 9 | 10 | namespace DedicatedServer.MessageCommands 11 | { 12 | internal class DemolishCommandListener 13 | { 14 | private EventDrivenChatBox chatBox; 15 | 16 | public DemolishCommandListener(EventDrivenChatBox chatBox) 17 | { 18 | this.chatBox = chatBox; 19 | } 20 | 21 | public void Enable() 22 | { 23 | chatBox.ChatReceived += chatReceived; 24 | } 25 | 26 | public void Disable() 27 | { 28 | chatBox.ChatReceived -= chatReceived; 29 | } 30 | 31 | private void destroyCabin(string farmerName, Building building, Farm f) 32 | { 33 | Action buildingLockFailed = delegate 34 | { 35 | chatBox.textBoxEnter("/message " + farmerName + " Error: " + Game1.content.LoadString("Strings\\UI:Carpenter_CantDemolish_LockFailed")); 36 | }; 37 | Action continueDemolish = delegate 38 | { 39 | if (building.daysOfConstructionLeft.Value > 0 || building.daysUntilUpgrade.Value > 0) 40 | { 41 | chatBox.textBoxEnter("/message " + farmerName + " Error: " + Game1.content.LoadString("Strings\\UI:Carpenter_CantDemolish_DuringConstruction")); 42 | } 43 | else if (building.indoors.Value != null && building.indoors.Value is AnimalHouse && (building.indoors.Value as AnimalHouse).animalsThatLiveHere.Count > 0) 44 | { 45 | chatBox.textBoxEnter("/message " + farmerName + " Error: " + Game1.content.LoadString("Strings\\UI:Carpenter_CantDemolish_AnimalsHere")); 46 | } 47 | else if (building.indoors.Value != null && building.indoors.Value.farmers.Any()) 48 | { 49 | chatBox.textBoxEnter("/message " + farmerName + " Error: " + Game1.content.LoadString("Strings\\UI:Carpenter_CantDemolish_PlayerHere")); 50 | } 51 | else 52 | { 53 | if (building.indoors.Value != null && building.indoors.Value is Cabin) 54 | { 55 | foreach (Farmer allFarmer in Game1.getAllFarmers()) 56 | { 57 | if (allFarmer.currentLocation != null && allFarmer.currentLocation.Name == (building.indoors.Value as Cabin).GetCellarName()) 58 | { 59 | chatBox.textBoxEnter("/message " + farmerName + " Error: " + Game1.content.LoadString("Strings\\UI:Carpenter_CantDemolish_PlayerHere")); 60 | return; 61 | } 62 | } 63 | } 64 | 65 | if (building.indoors.Value is Cabin && (building.indoors.Value as Cabin).farmhand.Value.isActive()) 66 | { 67 | chatBox.textBoxEnter("/message " + farmerName + " Error: " + Game1.content.LoadString("Strings\\UI:Carpenter_CantDemolish_FarmhandOnline")); 68 | } 69 | else 70 | { 71 | building.BeforeDemolish(); 72 | Chest chest = null; 73 | if (building.indoors.Value is Cabin) 74 | { 75 | List list = (building.indoors.Value as Cabin).demolish(); 76 | if (list.Count > 0) 77 | { 78 | chest = new Chest(playerChest: true); 79 | chest.fixLidFrame(); 80 | chest.items.Set(list); 81 | } 82 | } 83 | 84 | if (f.destroyStructure(building)) 85 | { 86 | _ = building.tileY.Value; 87 | _ = building.tilesHigh.Value; 88 | Game1.flashAlpha = 1f; 89 | building.showDestroyedAnimation(Game1.getFarm()); 90 | Utility.spreadAnimalsAround(building, f); 91 | if (chest != null) 92 | { 93 | f.objects[new Vector2(building.tileX.Value + building.tilesWide.Value / 2, building.tileY.Value + building.tilesHigh.Value / 2)] = chest; 94 | } 95 | } 96 | } 97 | } 98 | }; 99 | 100 | Game1.player.team.demolishLock.RequestLock(continueDemolish, buildingLockFailed); 101 | } 102 | 103 | private Action genDestroyCabinAction(string farmerName, Building building) 104 | { 105 | void destroyCabinAction() 106 | { 107 | Farm f = Game1.getFarm(); 108 | destroyCabin(farmerName, building, f); 109 | } 110 | 111 | return destroyCabinAction; 112 | } 113 | 114 | private Action genCancelDestroyCabinAction(string farmerName) 115 | { 116 | void cancelDestroyCabinAction() 117 | { 118 | chatBox.textBoxEnter("/message " + farmerName + " Action canceled."); 119 | } 120 | 121 | return cancelDestroyCabinAction; 122 | } 123 | 124 | private void chatReceived(object sender, ChatEventArgs e) 125 | { 126 | var tokens = e.Message.ToLower().Split(' '); 127 | if (tokens.Length == 0) 128 | { 129 | return; 130 | } 131 | // Private message chatKind is 3 132 | if (e.ChatKind == 3 && tokens[0] == "demolish") 133 | { 134 | // Find the farmer it came from and determine their location 135 | foreach (var farmer in Game1.otherFarmers.Values) 136 | { 137 | if (farmer.UniqueMultiplayerID == e.SourceFarmerId) 138 | { 139 | if (tokens.Length != 1) 140 | { 141 | chatBox.textBoxEnter("/message " + farmer.Name + " Error: Invalid command usage."); 142 | chatBox.textBoxEnter("/message " + farmer.Name + " Usage: demolish"); 143 | return; 144 | } 145 | var location = farmer.currentLocation; 146 | if (location is Farm f) 147 | { 148 | var tileLocation = farmer.getTileLocation(); 149 | switch (farmer.facingDirection.Value) 150 | { 151 | case 1: // Right 152 | tileLocation.X++; 153 | break; 154 | case 2: // Down 155 | tileLocation.Y++; 156 | break; 157 | case 3: // Left 158 | tileLocation.X--; 159 | break; 160 | default: // 0 = up 161 | tileLocation.Y--; 162 | break; 163 | } 164 | foreach (var building in f.buildings) 165 | { 166 | if (building.occupiesTile(tileLocation)) 167 | { 168 | // Determine if the building can be demolished 169 | var demolishCheckBlueprint = new BluePrint(building.buildingType.Value); 170 | if (demolishCheckBlueprint.moneyRequired < 0) 171 | { 172 | // Hard-coded magic number (< 0) means it cannot be demolished 173 | chatBox.textBoxEnter("/message " + farmer.Name + " Error: This building can't be demolished."); 174 | return; 175 | } 176 | else if (demolishCheckBlueprint.name == "Shipping Bin") 177 | { 178 | int num = 0; 179 | foreach (var b in Game1.getFarm().buildings) 180 | { 181 | if (b is ShippingBin) 182 | { 183 | num++; 184 | } 185 | 186 | if (num > 1) 187 | { 188 | break; 189 | } 190 | } 191 | 192 | if (num <= 1) 193 | { 194 | // Must have at least one shipping bin at all times. 195 | chatBox.textBoxEnter("/message " + farmer.Name + " Error: Can't demolish the last shipping bin."); 196 | return; 197 | } 198 | } 199 | 200 | if (building.indoors.Value is Cabin) 201 | { 202 | Cabin cabin = building.indoors.Value as Cabin; 203 | if (cabin.farmhand.Value != null && cabin.farmhand.Value.isCustomized.Value) 204 | { 205 | // The cabin is owned by someone. Ask the player if they're certain; record in memory the action to destroy the building. 206 | var responseActions = new Dictionary(); 207 | responseActions["yes"] = genDestroyCabinAction(farmer.Name, building); 208 | responseActions["no"] = genCancelDestroyCabinAction(farmer.Name); 209 | chatBox.RegisterFarmerResponseActionGroup(farmer.UniqueMultiplayerID, responseActions); 210 | chatBox.textBoxEnter("/message " + farmer.Name + " This cabin belongs to a player. Are you sure you want to remove it? Message me \"yes\" or \"no\"."); 211 | return; 212 | } 213 | } 214 | 215 | // The cabin doesn't belong to anyone. Destroy it immediately without confirmation. 216 | destroyCabin(farmer.Name, building, f); 217 | return; 218 | } 219 | } 220 | 221 | chatBox.textBoxEnter("/message " + farmer.Name + " Error: No building found. You must be standing next to a building and facing it."); 222 | } 223 | else 224 | { 225 | chatBox.textBoxEnter("/message " + farmer.Name + " Error: You cannot demolish buildings outside of the farm."); 226 | } 227 | break; 228 | } 229 | } 230 | } 231 | } 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /DedicatedServer/MessageCommands/PauseCommandListener.cs: -------------------------------------------------------------------------------- 1 | using DedicatedServer.Chat; 2 | using StardewValley; 3 | 4 | namespace DedicatedServer.MessageCommands 5 | { 6 | internal class PauseCommandListener 7 | { 8 | private EventDrivenChatBox chatBox; 9 | 10 | public PauseCommandListener(EventDrivenChatBox chatBox) 11 | { 12 | this.chatBox = chatBox; 13 | } 14 | 15 | public void Enable() 16 | { 17 | chatBox.ChatReceived += chatReceived; 18 | } 19 | 20 | public void Disable() 21 | { 22 | chatBox.ChatReceived -= chatReceived; 23 | } 24 | 25 | private void chatReceived(object sender, ChatEventArgs e) 26 | { 27 | var tokens = e.Message.ToLower().Split(' '); 28 | if (tokens.Length == 0) 29 | { 30 | return; 31 | } 32 | // Private message chatKind is 3 33 | if (e.ChatKind == 3 && tokens[0] == "pause") 34 | { 35 | 36 | Game1.netWorldState.Value.IsPaused = !Game1.netWorldState.Value.IsPaused; 37 | if (Game1.netWorldState.Value.IsPaused) 38 | { 39 | chatBox.globalInfoMessage("Paused"); 40 | return; 41 | } 42 | chatBox.globalInfoMessage("Resumed"); 43 | } 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /DedicatedServer/MessageCommands/ServerCommandListener.cs: -------------------------------------------------------------------------------- 1 | using DedicatedServer.Chat; 2 | using DedicatedServer.Config; 3 | using StardewModdingAPI; 4 | using StardewValley; 5 | using StardewValley.Menus; 6 | using System; 7 | using System.Collections.Generic; 8 | using System.Linq; 9 | 10 | namespace DedicatedServer.MessageCommands 11 | { 12 | internal class ServerCommandListener 13 | { 14 | private EventDrivenChatBox chatBox; 15 | 16 | private ModConfig config; 17 | 18 | private IModHelper helper; 19 | 20 | public ServerCommandListener(IModHelper helper, ModConfig config, EventDrivenChatBox chatBox) 21 | { 22 | this.helper = helper; 23 | this.config = config; 24 | this.chatBox = chatBox; 25 | } 26 | 27 | public void Enable() 28 | { 29 | chatBox.ChatReceived += chatReceived; 30 | } 31 | 32 | public void Disable() 33 | { 34 | chatBox.ChatReceived -= chatReceived; 35 | } 36 | 37 | private void chatReceived(object sender, ChatEventArgs e) 38 | { 39 | var tokens = e.Message.Split(' '); 40 | 41 | if (tokens.Length == 0) { return; } 42 | 43 | tokens[0] = tokens[0].ToLower(); 44 | 45 | // As the host you can run commands in the chat box, using a forward slash(/) before the command. 46 | // See: 47 | var moveBuildPermissionCommand = new List() { "mbp", "movebuildpermission", "movepermissiong" }; 48 | 49 | if( (ChatBox.privateMessage == e.ChatKind ) && 50 | (moveBuildPermissionCommand.Any(tokens[0].Equals) ) ) 51 | { 52 | string newBuildPermission; 53 | 54 | if (2 == tokens.Length) 55 | { 56 | newBuildPermission = tokens[1].ToLower(); 57 | } 58 | else 59 | { 60 | newBuildPermission = ""; 61 | } 62 | 63 | var sourceFarmer = Game1.otherFarmers.Values 64 | .Where( farmer => farmer.UniqueMultiplayerID == e.SourceFarmerId) 65 | .FirstOrDefault()? 66 | .Name ?? Game1.player.Name; 67 | 68 | var moveBuildPermissionParameter = new List() { "off", "owned", "on" }; 69 | 70 | if (moveBuildPermissionParameter.Any(newBuildPermission.Equals)) 71 | { 72 | if (config.MoveBuildPermission == newBuildPermission) 73 | { 74 | chatBox.textBoxEnter("/message " + sourceFarmer + " Error: The parameter is already " + config.MoveBuildPermission); 75 | } 76 | else 77 | { 78 | config.MoveBuildPermission = newBuildPermission; 79 | chatBox.textBoxEnter(sourceFarmer + " Changed MoveBuildPermission to " + config.MoveBuildPermission); 80 | chatBox.textBoxEnter("/mbp " + config.MoveBuildPermission); 81 | helper.WriteConfig(config); 82 | } 83 | } 84 | else 85 | { 86 | chatBox.textBoxEnter("/message " + sourceFarmer + " Error: Only the following parameter are valid: " + String.Join(", ", moveBuildPermissionParameter.ToArray())); 87 | } 88 | } 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /DedicatedServer/ModEntry.cs: -------------------------------------------------------------------------------- 1 | using DedicatedServer.Config; 2 | using DedicatedServer.HostAutomatorStages; 3 | using StardewModdingAPI; 4 | using StardewModdingAPI.Events; 5 | using StardewValley; 6 | 7 | namespace DedicatedServer 8 | { 9 | /// The mod entry point. 10 | public class ModEntry : Mod 11 | { 12 | // TODO ModConfig value checking. But perhaps this actually should be done in the SelectFarmStage; if the 13 | // farm with the name given by the config exists, then none of the rest of the config values really matter, 14 | // except for the bat / mushroom decision and the pet name (the parts accessed mid-game rather than just at 15 | // farm creation). 16 | 17 | // TODO Add more config options, like the ability to disable the crop saver (perhaps still keep track of crops 18 | // in case it's enabled later, but don't alter them). 19 | 20 | // TODO Remove player limit (if the existing attempts haven't already succeeded in doing that). 21 | 22 | // TODO Make the host invisible to everyone else 23 | 24 | // TODO Consider what the automated host should do when another player proposes to them. 25 | 26 | private WaitCondition titleMenuWaitCondition; 27 | private ModConfig config; 28 | private bool farmStageEnabled; 29 | 30 | /********* 31 | ** Public methods 32 | *********/ 33 | /// The mod entry point, called after the mod is first loaded. 34 | /// Provides simplified APIs for writing mods. 35 | public override void Entry(IModHelper helper) 36 | { 37 | this.config = helper.ReadConfig(); 38 | 39 | // ensure that the game environment is in a stable state before the mod starts executing 40 | this.titleMenuWaitCondition = new WaitCondition(() => Game1.activeClickableMenu is StardewValley.Menus.TitleMenu, 5); 41 | helper.Events.GameLoop.UpdateTicked += OnUpdateTicked; 42 | } 43 | 44 | /// 45 | /// Event handler to wait until a specific condition is met before executing. 46 | /// 47 | private void OnUpdateTicked(object sender, UpdateTickedEventArgs e) 48 | { 49 | if (!this.farmStageEnabled && this.titleMenuWaitCondition.IsMet()) 50 | { 51 | this.farmStageEnabled = true; // Set the flag to true once the condition is met. 52 | new StartFarmStage(this.Helper, Monitor, config).Enable(); 53 | } 54 | // makes the host stamina and health infinite 55 | if (Context.IsWorldReady) 56 | { 57 | Game1.player.health = Game1.player.maxHealth; 58 | Game1.player.stamina = Game1.player.maxStamina; 59 | } 60 | } 61 | 62 | /// 63 | /// Represents wait condition. 64 | /// 65 | private class WaitCondition 66 | { 67 | private readonly System.Func condition; 68 | private int waitCounter; 69 | 70 | public WaitCondition(System.Func condition, int initialWait) 71 | { 72 | this.condition = condition; 73 | this.waitCounter = initialWait; 74 | } 75 | 76 | public bool IsMet() 77 | { 78 | if (this.waitCounter <= 0 && this.condition()) 79 | { 80 | return true; 81 | } 82 | 83 | this.waitCounter--; 84 | return false; 85 | } 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /DedicatedServer/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "DedicatedServer": { 4 | "commandName": "Project" 5 | } 6 | } 7 | } -------------------------------------------------------------------------------- /DedicatedServer/Utils/Festivals.cs: -------------------------------------------------------------------------------- 1 | using DedicatedServer.HostAutomatorStages; 2 | using StardewValley; 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | using System.Text; 7 | using System.Threading.Tasks; 8 | 9 | namespace DedicatedServer.Utils 10 | { 11 | internal class Festivals 12 | { 13 | private static int getFestivalEndTime() 14 | { 15 | if (Game1.weatherIcon == 1) 16 | { 17 | return Convert.ToInt32(Game1.temporaryContent.Load>("Data\\Festivals\\" + Game1.currentSeason + Game1.dayOfMonth)["conditions"].Split('/')[1].Split(' ')[1]); 18 | } 19 | 20 | return -1; 21 | } 22 | public static bool IsWaitingToAttend() 23 | { 24 | return ReadyCheckHelper.IsReady("festivalStart", Game1.player); 25 | } 26 | public static bool OthersWaitingToAttend(int numOtherPlayers) 27 | { 28 | return Game1.player.team.GetNumberReady("festivalStart") == (numOtherPlayers + (IsWaitingToAttend() ? 1 : 0)); 29 | } 30 | private static bool isTodayBeachNightMarket() 31 | { 32 | return Game1.currentSeason.Equals("winter") && Game1.dayOfMonth >= 15 && Game1.dayOfMonth <= 17; 33 | } 34 | public static bool ShouldAttend(int numOtherPlayers) 35 | { 36 | return numOtherPlayers > 0 && OthersWaitingToAttend(numOtherPlayers) && Utility.isFestivalDay(Game1.dayOfMonth, Game1.currentSeason) && !isTodayBeachNightMarket() && Game1.timeOfDay >= Utility.getStartTimeOfFestival() && Game1.timeOfDay <= getFestivalEndTime(); 37 | } 38 | 39 | public static bool IsWaitingToLeave() 40 | { 41 | return ReadyCheckHelper.IsReady("festivalEnd", Game1.player); 42 | } 43 | public static bool OthersWaitingToLeave(int numOtherPlayers) 44 | { 45 | return Game1.player.team.GetNumberReady("festivalEnd") == (numOtherPlayers + (IsWaitingToLeave() ? 1 : 0)); 46 | } 47 | public static bool ShouldLeave(int numOtherPlayers) 48 | { 49 | return Game1.isFestival() && OthersWaitingToLeave(numOtherPlayers); 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /DedicatedServer/Utils/SerializableDictionary.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Xml.Serialization; 3 | 4 | namespace DedicatedServer.Utils 5 | { 6 | [XmlRoot("dictionary")] 7 | public class SerializableDictionary 8 | : Dictionary, IXmlSerializable 9 | { 10 | public SerializableDictionary() { } 11 | public SerializableDictionary(IDictionary dictionary) : base(dictionary) { } 12 | public SerializableDictionary(IDictionary dictionary, IEqualityComparer comparer) : base(dictionary, comparer) { } 13 | public SerializableDictionary(IEqualityComparer comparer) : base(comparer) { } 14 | public SerializableDictionary(int capacity) : base(capacity) { } 15 | public SerializableDictionary(int capacity, IEqualityComparer comparer) : base(capacity, comparer) { } 16 | 17 | #region IXmlSerializable Members 18 | public System.Xml.Schema.XmlSchema GetSchema() 19 | { 20 | return null; 21 | } 22 | 23 | public void ReadXml(System.Xml.XmlReader reader) 24 | { 25 | XmlSerializer keySerializer = new XmlSerializer(typeof(TKey)); 26 | XmlSerializer valueSerializer = new XmlSerializer(typeof(TValue)); 27 | 28 | bool wasEmpty = reader.IsEmptyElement; 29 | reader.Read(); 30 | 31 | if (wasEmpty) 32 | return; 33 | 34 | while (reader.NodeType != System.Xml.XmlNodeType.EndElement) 35 | { 36 | reader.ReadStartElement("item"); 37 | 38 | reader.ReadStartElement("key"); 39 | TKey key = (TKey)keySerializer.Deserialize(reader); 40 | reader.ReadEndElement(); 41 | 42 | reader.ReadStartElement("value"); 43 | TValue value = (TValue)valueSerializer.Deserialize(reader); 44 | reader.ReadEndElement(); 45 | 46 | this.Add(key, value); 47 | 48 | reader.ReadEndElement(); 49 | reader.MoveToContent(); 50 | } 51 | reader.ReadEndElement(); 52 | } 53 | 54 | public void WriteXml(System.Xml.XmlWriter writer) 55 | { 56 | XmlSerializer keySerializer = new XmlSerializer(typeof(TKey)); 57 | XmlSerializer valueSerializer = new XmlSerializer(typeof(TValue)); 58 | 59 | foreach (TKey key in this.Keys) 60 | { 61 | writer.WriteStartElement("item"); 62 | 63 | writer.WriteStartElement("key"); 64 | keySerializer.Serialize(writer, key); 65 | writer.WriteEndElement(); 66 | 67 | writer.WriteStartElement("value"); 68 | TValue value = this[key]; 69 | valueSerializer.Serialize(writer, value); 70 | writer.WriteEndElement(); 71 | 72 | writer.WriteEndElement(); 73 | } 74 | } 75 | #endregion 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /DedicatedServer/Utils/Sleeping.cs: -------------------------------------------------------------------------------- 1 | using DedicatedServer.HostAutomatorStages; 2 | using Netcode; 3 | using StardewValley; 4 | using StardewValley.Network; 5 | using System; 6 | using System.Collections.Generic; 7 | using System.Reflection; 8 | 9 | namespace DedicatedServer.Utils 10 | { 11 | internal class Sleeping 12 | { 13 | public static bool IsSleeping() 14 | { 15 | return ReadyCheckHelper.IsReady("sleep", Game1.player); 16 | } 17 | public static bool OthersInBed(int numOtherPlayers) 18 | { 19 | return Game1.player.team.GetNumberReady("sleep") == (numOtherPlayers + (IsSleeping() ? 1 : 0)); 20 | } 21 | public static bool ShouldSleep(int numOtherPlayers) 22 | { 23 | return numOtherPlayers > 0 && (Game1.timeOfDay >= 2530 || OthersInBed(numOtherPlayers)); 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /DedicatedServer/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "Name": "DedicatedServer", 3 | "Author": "ObjectManagerManager", 4 | "Version": "1.0.1", 5 | "Description": "Description", 6 | "UniqueID": "objectmanagermanager.DedicatedServer", 7 | "EntryDll": "DedicatedServer.dll", 8 | "MinimumApiVersion": "3.0.0", 9 | "UpdateKeys": [] 10 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 ObjectManagerManager 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SMAPI Dedicated Server Mod for Stardew Valley 2 | This mod provides a dedicated (headless) server for Stardew Valley, powered by SMAPI. It turns the host farmer into an automated bot to facilitate multiplayer gameplay. 3 | 4 | ## Configuration File 5 | Upon running SMAPI with the mod installed for the first time, a `config.json` file will be generated in the mod's folder. This file specifies which farm will be loaded on startup, farm creation options, host automation details, and other mod configuration options. Default values will be provided, which can then be modified. Here is an overview of the available settings: 6 | 7 | ### Startup options 8 | 9 | - `FarmName`: The name of the farm. If a farm with this name exists, it will automatically be loaded and hosted for co-op. Otherwise, a new farm will be created using the specified farm creation options and then hosted for co-op. 10 | 11 | ### Farm Creation Options 12 | 13 | - `StartingCabins`: The number of starting cabins for the farm. Must be an integer in {0, 1, 2, 3}. 14 | - `CabinLayout`: Specifies the starting cabin layout. Options are "nearby" or "separate". 15 | - `ProfitMargin`: The farm's profit margin. Options are "normal", "75%", "50%", and "25%". 16 | - `MoneyStyle`: Determines whether money is shared or separate among farmers. Options are "shared" or "separate". 17 | - `FarmType`: The type of farm. Options include "standard", "riverland", "forest", "hilltop", "wilderness", "fourcorners", and "beach". 18 | - `CommunityCenterBundles`: The community center bundle type. Options are "normal" or "remixed". 19 | - `GuaranteeYear1Completable`: Set to `true` or `false` to determine if the community center should be guaranteed completable during the first year. 20 | - `MineRewards`: The mine rewards type. Options are "normal" or "remixed". 21 | - `SpawnMonstersOnFarmAtNight`: Set to `true` or `false` to determine if monsters should spawn on the farm at night. 22 | - `RandomSeed`: An optional integer specifying the farm's random seed. 23 | 24 | ### Host Automation Options 25 | 26 | - `AcceptPet`: Set to `true` or `false` to determine if the farm pet should be accepted. 27 | - `PetSpecies`: The desired pet species. Options are "dog" or "cat". Irrelevant if `AcceptPet` is `false`. 28 | - `PetBreed`: An integer in {0, 1, 2} specifying the pet breed index. 0 selects the leftmost breed; 1 selects the middle breed; 2 selects the rightmost breed. Irrelevant if `AcceptPet` is `false`. 29 | - `PetName`: The desired pet name. Irrelevant if `AcceptPet` is `false`. 30 | - `MushroomsOrBats`: Choose between the mushroom or bat cave. Options are "mushrooms" or "bats" (case insensitive). 31 | - `PurchaseJojaMembership`: Set to `true` or `false` to determine if the automated host should "purchase" (acquire for free) a Joja membership when available, committing to the Joja route. Defaults to `false`. 32 | 33 | ### Additional Options 34 | 35 | - `EnableCropSaver`: Set to `true` or `false` to enable or disable the crop saver feature. When enabled, seasonal crops planted by players and fully grown before the season's end are guaranteed to give at least one more harvest before dying. For example, a spring crop planted by a player and fully grown before Summer 1 will not die immediately on Summer 1. Instead, it'll provide exactly one more harvest, even if it's a crop that ordinarily produces multiple harvests. Defaults to `true`. 36 | 37 | ### Host Options 38 | 39 | - `MoveBuildPermission`: Changes farmhands permissions to move buildings from the Carpenter's shop. Is set each time the server is started and can be changed in the game. Set to `off` to entirely disable moving buildings, set to `owned` to allow farmhands to move buildings that they purchased, or set to `on` to allow moving all buildings. 40 | 41 | ## In Game Command 42 | 43 | All commands in the game must be sent privately to the player `ServerBot`. For example, you must write the following `/message ServerBot MoveBuildPermission on`. 44 | 45 | - `MoveBuildPermission`: Changes farmhands permissions to move buildings from the Carpenter's shop. Set to `off` to entirely disable moving buildings, set to `owned` to allow farmhands to move buildings that they purchased, or set to `on` to allow moving all buildings. The changes are transferred to the `config.json` file. 46 | - `movepermissiong`: Alias for MoveBuildPermission 47 | - `mbp`: Alias for MoveBuildPermission 48 | 49 | ## Running the Server on Linux Without GUI 50 | 51 | This mod can be run without the use of a GUI. To start the game, you must enter the following command: 52 | 53 | ```bash 54 | xvfb-run -a "$HOME/GOG Games/Stardew Valley/game/StardewModdingAPI" 55 | ``` 56 | 57 | You can shut down the server from the started terminal session by pressing `Control-C`. 58 | From another terminal session, you can send `Control-C` with `kill -SIGINT ....`. 59 | 60 | ```bash 61 | ps -aux | grep StardewModdingAPI 62 | kill -SIGINT .... 63 | ``` 64 | 65 | ## Development 66 | 67 | If Stardew Valley was not installed in the default path, the installation path must be added to the project file `DedicatedServer.csproj`. Add the path with the tag `GamePath` to the `PropertyGroup`. Depending on the path, it should look something like this: 68 | 69 | ```text 70 | 71 | net5.0 72 | D:\SteamLibrary\steamapps\common\Stardew Valley 73 | 74 | ``` 75 | --------------------------------------------------------------------------------