import csv import enum from dataclasses import dataclass from random import Random from typing import Optional, Dict, Protocol, List, FrozenSet from . import options, data from .data.fish_data import legendary_fish, special_fish, all_fish from .data.museum_data import all_museum_items from .data.villagers_data import all_villagers from .strings.goal_names import Goal from .strings.region_names import Region LOCATION_CODE_OFFSET = 717000 class LocationTags(enum.Enum): MANDATORY = enum.auto() BUNDLE = enum.auto() COMMUNITY_CENTER_BUNDLE = enum.auto() CRAFTS_ROOM_BUNDLE = enum.auto() PANTRY_BUNDLE = enum.auto() FISH_TANK_BUNDLE = enum.auto() BOILER_ROOM_BUNDLE = enum.auto() BULLETIN_BOARD_BUNDLE = enum.auto() VAULT_BUNDLE = enum.auto() COMMUNITY_CENTER_ROOM = enum.auto() BACKPACK = enum.auto() TOOL_UPGRADE = enum.auto() HOE_UPGRADE = enum.auto() PICKAXE_UPGRADE = enum.auto() AXE_UPGRADE = enum.auto() WATERING_CAN_UPGRADE = enum.auto() TRASH_CAN_UPGRADE = enum.auto() FISHING_ROD_UPGRADE = enum.auto() THE_MINES_TREASURE = enum.auto() CROPSANITY = enum.auto() ELEVATOR = enum.auto() SKILL_LEVEL = enum.auto() FARMING_LEVEL = enum.auto() FISHING_LEVEL = enum.auto() FORAGING_LEVEL = enum.auto() COMBAT_LEVEL = enum.auto() MINING_LEVEL = enum.auto() BUILDING_BLUEPRINT = enum.auto() QUEST = enum.auto() ARCADE_MACHINE = enum.auto() ARCADE_MACHINE_VICTORY = enum.auto() JOTPK = enum.auto() JUNIMO_KART = enum.auto() HELP_WANTED = enum.auto() TRAVELING_MERCHANT = enum.auto() FISHSANITY = enum.auto() MUSEUM_MILESTONES = enum.auto() MUSEUM_DONATIONS = enum.auto() FRIENDSANITY = enum.auto() FESTIVAL = enum.auto() FESTIVAL_HARD = enum.auto() SPECIAL_ORDER_BOARD = enum.auto() SPECIAL_ORDER_QI = enum.auto() GINGER_ISLAND = enum.auto() WALNUT_PURCHASE = enum.auto() # Skill Mods LUCK_LEVEL = enum.auto() BINNING_LEVEL = enum.auto() COOKING_LEVEL = enum.auto() SOCIALIZING_LEVEL = enum.auto() MAGIC_LEVEL = enum.auto() ARCHAEOLOGY_LEVEL = enum.auto() @dataclass(frozen=True) class LocationData: code_without_offset: Optional[int] region: str name: str mod_name: Optional[str] = None tags: FrozenSet[LocationTags] = frozenset() @property def code(self) -> Optional[int]: return LOCATION_CODE_OFFSET + self.code_without_offset if self.code_without_offset is not None else None class StardewLocationCollector(Protocol): def __call__(self, name: str, code: Optional[int], region: str) -> None: raise NotImplementedError def load_location_csv() -> List[LocationData]: try: from importlib.resources import files except ImportError: from importlib_resources import files with files(data).joinpath("locations.csv").open() as file: reader = csv.DictReader(file) return [LocationData(int(location["id"]) if location["id"] else None, location["region"], location["name"], str(location["mod_name"]) if location["mod_name"] else None, frozenset(LocationTags[group] for group in location["tags"].split(",") if group)) for location in reader] events_locations = [ LocationData(None, Region.farm_house, Goal.grandpa_evaluation), LocationData(None, Region.community_center, Goal.community_center), LocationData(None, Region.mines_floor_120, Goal.bottom_of_the_mines), LocationData(None, Region.skull_cavern_100, Goal.cryptic_note), LocationData(None, Region.farm, Goal.master_angler), LocationData(None, Region.museum, Goal.complete_museum), LocationData(None, Region.farm_house, Goal.full_house), LocationData(None, Region.island_west, Goal.greatest_walnut_hunter), LocationData(None, Region.qi_walnut_room, Goal.perfection), ] all_locations = load_location_csv() + events_locations location_table: Dict[str, LocationData] = {location.name: location for location in all_locations} locations_by_tag: Dict[LocationTags, List[LocationData]] = {} def initialize_groups(): for location in all_locations: for tag in location.tags: location_group = locations_by_tag.get(tag, list()) location_group.append(location) locations_by_tag[tag] = location_group initialize_groups() def extend_cropsanity_locations(randomized_locations: List[LocationData], world_options): if world_options[options.Cropsanity] == options.Cropsanity.option_disabled: return cropsanity_locations = locations_by_tag[LocationTags.CROPSANITY] cropsanity_locations = filter_ginger_island(world_options, cropsanity_locations) randomized_locations.extend(cropsanity_locations) def extend_help_wanted_quests(randomized_locations: List[LocationData], desired_number_of_quests: int): for i in range(0, desired_number_of_quests): batch = i // 7 index_this_batch = i % 7 if index_this_batch < 4: randomized_locations.append( location_table[f"Help Wanted: Item Delivery {(batch * 4) + index_this_batch + 1}"]) elif index_this_batch == 4: randomized_locations.append(location_table[f"Help Wanted: Fishing {batch + 1}"]) elif index_this_batch == 5: randomized_locations.append(location_table[f"Help Wanted: Slay Monsters {batch + 1}"]) elif index_this_batch == 6: randomized_locations.append(location_table[f"Help Wanted: Gathering {batch + 1}"]) def extend_fishsanity_locations(randomized_locations: List[LocationData], world_options, random: Random): prefix = "Fishsanity: " if world_options[options.Fishsanity] == options.Fishsanity.option_none: return elif world_options[options.Fishsanity] == options.Fishsanity.option_legendaries: randomized_locations.extend(location_table[f"{prefix}{legendary.name}"] for legendary in legendary_fish) elif world_options[options.Fishsanity] == options.Fishsanity.option_special: randomized_locations.extend(location_table[f"{prefix}{special.name}"] for special in special_fish) elif world_options[options.Fishsanity] == options.Fishsanity.option_randomized: fish_locations = [location_table[f"{prefix}{fish.name}"] for fish in all_fish if random.random() < 0.4] randomized_locations.extend(filter_ginger_island(world_options, fish_locations)) elif world_options[options.Fishsanity] == options.Fishsanity.option_all: fish_locations = [location_table[f"{prefix}{fish.name}"] for fish in all_fish] randomized_locations.extend(filter_ginger_island(world_options, fish_locations)) elif world_options[options.Fishsanity] == options.Fishsanity.option_exclude_legendaries: fish_locations = [location_table[f"{prefix}{fish.name}"] for fish in all_fish if fish not in legendary_fish] randomized_locations.extend(filter_ginger_island(world_options, fish_locations)) elif world_options[options.Fishsanity] == options.Fishsanity.option_exclude_hard_fish: fish_locations = [location_table[f"{prefix}{fish.name}"] for fish in all_fish if fish.difficulty < 80] randomized_locations.extend(filter_ginger_island(world_options, fish_locations)) elif world_options[options.Fishsanity] == options.Fishsanity.option_only_easy_fish: fish_locations = [location_table[f"{prefix}{fish.name}"] for fish in all_fish if fish.difficulty < 50] randomized_locations.extend(filter_ginger_island(world_options, fish_locations)) def extend_museumsanity_locations(randomized_locations: List[LocationData], museumsanity: int, random: Random): prefix = "Museumsanity: " if museumsanity == options.Museumsanity.option_none: return elif museumsanity == options.Museumsanity.option_milestones: randomized_locations.extend(locations_by_tag[LocationTags.MUSEUM_MILESTONES]) elif museumsanity == options.Museumsanity.option_randomized: randomized_locations.extend(location_table[f"{prefix}{museum_item.name}"] for museum_item in all_museum_items if random.random() < 0.4) elif museumsanity == options.Museumsanity.option_all: randomized_locations.extend(location_table[f"{prefix}{museum_item.name}"] for museum_item in all_museum_items) def extend_friendsanity_locations(randomized_locations: List[LocationData], world_options: options.StardewOptions): if world_options[options.Friendsanity] == options.Friendsanity.option_none: return exclude_leo = world_options[options.ExcludeGingerIsland] == options.ExcludeGingerIsland.option_true exclude_non_bachelors = world_options[options.Friendsanity] == options.Friendsanity.option_bachelors exclude_locked_villagers = world_options[options.Friendsanity] == options.Friendsanity.option_starting_npcs or \ world_options[options.Friendsanity] == options.Friendsanity.option_bachelors include_post_marriage_hearts = world_options[options.Friendsanity] == options.Friendsanity.option_all_with_marriage heart_size = world_options[options.FriendsanityHeartSize] for villager in all_villagers: if villager.mod_name not in world_options[options.Mods] and villager.mod_name is not None: continue if not villager.available and exclude_locked_villagers: continue if not villager.bachelor and exclude_non_bachelors: continue if villager.name == "Leo" and exclude_leo: continue heart_cap = 8 if villager.bachelor else 10 if include_post_marriage_hearts and villager.bachelor: heart_cap = 14 for heart in range(1, 15): if heart > heart_cap: break if heart % heart_size == 0 or heart == heart_cap: randomized_locations.append(location_table[f"Friendsanity: {villager.name} {heart} <3"]) if not exclude_non_bachelors: for heart in range(1, 6): if heart % heart_size == 0 or heart == 5: randomized_locations.append(location_table[f"Friendsanity: Pet {heart} <3"]) def extend_festival_locations(randomized_locations: List[LocationData], festival_option: int): if festival_option == options.FestivalLocations.option_disabled: return festival_locations = locations_by_tag[LocationTags.FESTIVAL] randomized_locations.extend(festival_locations) extend_hard_festival_locations(randomized_locations, festival_option) def extend_hard_festival_locations(randomized_locations, festival_option: int): if festival_option != options.FestivalLocations.option_hard: return hard_festival_locations = locations_by_tag[LocationTags.FESTIVAL_HARD] randomized_locations.extend(hard_festival_locations) def extend_special_order_locations(randomized_locations: List[LocationData], world_options): if world_options[options.SpecialOrderLocations] == options.SpecialOrderLocations.option_disabled: return include_island = world_options[options.ExcludeGingerIsland] == options.ExcludeGingerIsland.option_false board_locations = filter_disabled_locations(world_options, locations_by_tag[LocationTags.SPECIAL_ORDER_BOARD]) randomized_locations.extend(board_locations) if world_options[options.SpecialOrderLocations] == options.SpecialOrderLocations.option_board_qi and include_island: include_arcade = world_options[options.ArcadeMachineLocations] != options.ArcadeMachineLocations.option_disabled qi_orders = [location for location in locations_by_tag[LocationTags.SPECIAL_ORDER_QI] if include_arcade or LocationTags.JUNIMO_KART not in location.tags] randomized_locations.extend(qi_orders) def extend_walnut_purchase_locations(randomized_locations: List[LocationData], world_options): if world_options[options.ExcludeGingerIsland] == options.ExcludeGingerIsland.option_true: return randomized_locations.append(location_table["Repair Ticket Machine"]) randomized_locations.append(location_table["Repair Boat Hull"]) randomized_locations.append(location_table["Repair Boat Anchor"]) randomized_locations.append(location_table["Open Professor Snail Cave"]) randomized_locations.append(location_table["Complete Island Field Office"]) randomized_locations.extend(locations_by_tag[LocationTags.WALNUT_PURCHASE]) def extend_mandatory_locations(randomized_locations: List[LocationData], world_options): mandatory_locations = [location for location in locations_by_tag[LocationTags.MANDATORY]] filtered_mandatory_locations = filter_disabled_locations(world_options, mandatory_locations) randomized_locations.extend(filtered_mandatory_locations) def extend_backpack_locations(randomized_locations: List[LocationData], world_options): if world_options[options.BackpackProgression] == options.BackpackProgression.option_vanilla: return backpack_locations = [location for location in locations_by_tag[LocationTags.BACKPACK]] filtered_backpack_locations = filter_modded_locations(world_options, backpack_locations) randomized_locations.extend(filtered_backpack_locations) def extend_elevator_locations(randomized_locations: List[LocationData], world_options): if world_options[options.ElevatorProgression] == options.ElevatorProgression.option_vanilla: return elevator_locations = [location for location in locations_by_tag[LocationTags.ELEVATOR]] filtered_elevator_locations = filter_modded_locations(world_options, elevator_locations) randomized_locations.extend(filtered_elevator_locations) def create_locations(location_collector: StardewLocationCollector, world_options: options.StardewOptions, random: Random): randomized_locations = [] extend_mandatory_locations(randomized_locations, world_options) extend_backpack_locations(randomized_locations, world_options) if not world_options[options.ToolProgression] == options.ToolProgression.option_vanilla: randomized_locations.extend(locations_by_tag[LocationTags.TOOL_UPGRADE]) extend_elevator_locations(randomized_locations, world_options) if not world_options[options.SkillProgression] == options.SkillProgression.option_vanilla: for location in locations_by_tag[LocationTags.SKILL_LEVEL]: if location.mod_name is None or location.mod_name in world_options[options.Mods]: randomized_locations.append(location_table[location.name]) if not world_options[options.BuildingProgression] == options.BuildingProgression.option_vanilla: for location in locations_by_tag[LocationTags.BUILDING_BLUEPRINT]: if location.mod_name is None or location.mod_name in world_options[options.Mods]: randomized_locations.append(location_table[location.name]) if world_options[options.ArcadeMachineLocations] != options.ArcadeMachineLocations.option_disabled: randomized_locations.extend(locations_by_tag[LocationTags.ARCADE_MACHINE_VICTORY]) if world_options[options.ArcadeMachineLocations] == options.ArcadeMachineLocations.option_full_shuffling: randomized_locations.extend(locations_by_tag[LocationTags.ARCADE_MACHINE]) extend_cropsanity_locations(randomized_locations, world_options) extend_help_wanted_quests(randomized_locations, world_options[options.HelpWantedLocations]) extend_fishsanity_locations(randomized_locations, world_options, random) extend_museumsanity_locations(randomized_locations, world_options[options.Museumsanity], random) extend_friendsanity_locations(randomized_locations, world_options) extend_festival_locations(randomized_locations, world_options[options.FestivalLocations]) extend_special_order_locations(randomized_locations, world_options) extend_walnut_purchase_locations(randomized_locations, world_options) for location_data in randomized_locations: location_collector(location_data.name, location_data.code, location_data.region) def filter_ginger_island(world_options: options.StardewOptions, locations: List[LocationData]) -> List[LocationData]: include_island = world_options[options.ExcludeGingerIsland] == options.ExcludeGingerIsland.option_false return [location for location in locations if include_island or LocationTags.GINGER_ISLAND not in location.tags] def filter_modded_locations(world_options: options.StardewOptions, locations: List[LocationData]) -> List[LocationData]: current_mod_names = world_options[options.Mods] return [location for location in locations if location.mod_name is None or location.mod_name in current_mod_names] def filter_disabled_locations(world_options: options.StardewOptions, locations: List[LocationData]) -> List[LocationData]: locations_first_pass = filter_ginger_island(world_options, locations) locations_second_pass = filter_modded_locations(world_options, locations_first_pass) return locations_second_pass