import logging import math import typing from collections import Counter from functools import wraps from random import Random from typing import Dict, List, Any, ClassVar, TextIO, Optional import entrance_rando from BaseClasses import Region, Location, Item, Tutorial, ItemClassification, MultiWorld, CollectionState from Options import PerGameCommonOptions from worlds.AutoWorld import World, WebWorld from worlds.LauncherComponents import components, Component, icon_paths, Type from .bundles.bundle_room import BundleRoom from .bundles.bundles import get_all_bundles, get_trash_bear_requests from .content import StardewContent, create_content from .content.feature.special_order_locations import get_qi_gem_amount from .content.feature.walnutsanity import get_walnut_amount from .items import item_table, ItemData, Group, items_by_group, create_items, generate_filler_choice_pool, \ setup_early_items from .items.item_data import FILLER_GROUPS from .locations import location_table, create_locations, LocationData, locations_by_tag from .logic.combat_logic import valid_weapons from .logic.logic import StardewLogic from .options import StardewValleyOptions, SeasonRandomization, Goal, BundleRandomization, EnabledFillerBuffs, \ NumberOfMovementBuffs, BuildingProgression, EntranceRandomization, ToolProgression, BackpackProgression, TrapDistribution, BundlePrice, \ BundleWhitelist, BundleBlacklist, BundlePerRoom, FarmType from .options.forced_options import force_change_options_if_incompatible, force_change_options_if_banned from .options.jojapocalypse_options import JojaAreYouSure from .options.option_groups import sv_option_groups from .options.presets import sv_options_presets from .options.settings import StardewSettings from .options.worlds_group import apply_most_restrictive_options from .regions import create_regions, prepare_mod_data from .rules import set_rules from .stardew_rule import True_, StardewRule, HasProgressionPercent from .strings.ap_names.ap_option_names import StartWithoutOptionName from .strings.ap_names.ap_weapon_names import APWeapon from .strings.ap_names.event_names import Event from .strings.goal_names import Goal as GoalName logger = logging.getLogger(__name__) STARDEW_VALLEY = "Stardew Valley" UNIVERSAL_TRACKER_SEED_PROPERTY = "ut_seed" client_version = 0 TRACKER_ENABLED = True class StardewLocation(Location): game: str = STARDEW_VALLEY class StardewItem(Item): game: str = STARDEW_VALLEY events_to_collect: Counter[str] @wraps(Item.__init__) def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) self.events_to_collect = Counter() class StardewWebWorld(WebWorld): theme = "dirt" bug_report_page = "https://github.com/agilbert1412/StardewArchipelago/issues/new?labels=bug&title=%5BBug%5D%3A+Brief+Description+of+bug+here" options_presets = sv_options_presets option_groups = sv_option_groups setup_en = Tutorial( "Multiworld Setup Guide", "A guide to playing Stardew Valley with Archipelago.", "English", "setup_en.md", "setup/en", ["Kaito Kid", "Jouramie", "Witchybun (Mod Support)", "Exempt-Medic (Proofreading)"] ) setup_fr = Tutorial( "Guide de configuration MultiWorld", "Un guide pour configurer Stardew Valley sur Archipelago", "Français", "setup_fr.md", "setup/fr", ["Eindall"] ) tutorials = [setup_en, setup_fr] if TRACKER_ENABLED: from .. import user_folder import os # Best effort to detect if universal tracker is installed if any("tracker.apworld" in f.name for f in os.scandir(user_folder)): def launch_client(*args): from worlds.LauncherComponents import launch from .client import launch as client_main launch(client_main, name="Stardew Valley Tracker", args=args) components.append(Component( "Stardew Valley Tracker", func=launch_client, component_type=Type.CLIENT, icon='stardew' )) icon_paths['stardew'] = f"ap:{__name__}/stardew.png" class StardewValleyWorld(World): """ Stardew Valley is an open-ended country-life RPG. You can farm, fish, mine, fight, complete quests, befriend villagers, and uncover dark secrets. """ game = STARDEW_VALLEY topology_present = True item_name_to_id = {name: data.code for name, data in item_table.items()} location_name_to_id = {name: data.code for name, data in location_table.items()} item_name_groups = { group.name.replace("_", " ").title() + (" Group" if group.name.replace("_", " ").title() in item_table else ""): [item.name for item in items] for group, items in items_by_group.items() } location_name_groups = { group.name.replace("_", " ").title() + (" Group" if group.name.replace("_", " ").title() in locations_by_tag else ""): [location.name for location in locations] for group, locations in locations_by_tag.items() } required_client_version = (0, 4, 0) options_dataclass = StardewValleyOptions options: StardewValleyOptions settings: ClassVar[StardewSettings] content: StardewContent logic: StardewLogic web = StardewWebWorld() modified_bundles: List[BundleRoom] randomized_entrances: Dict[str, str] trash_bear_requests: Dict[str, List[str]] total_progression_items: int classifications_to_override_post_fill: list[tuple[StardewItem, ItemClassification]] @classmethod def create_group(cls, multiworld: MultiWorld, new_player_id: int, players: set[int]) -> World: world_group = super().create_group(multiworld, new_player_id, players) group_options = typing.cast(StardewValleyOptions, world_group.options) worlds_options = [typing.cast(StardewValleyOptions, multiworld.worlds[player].options) for player in players] apply_most_restrictive_options(group_options, worlds_options) world_group.content = create_content(group_options) return world_group def __init__(self, multiworld: MultiWorld, player: int): super().__init__(multiworld, player) self.filler_item_pool_names = None self.total_progression_items = 0 self.classifications_to_override_post_fill = [] # Taking the seed specified in slot data for UT, otherwise just generating the seed. self.seed = getattr(multiworld, "re_gen_passthrough", {}).get(STARDEW_VALLEY, self.random.getrandbits(64)) self.random = Random(self.seed) def interpret_slot_data(self, slot_data: Dict[str, Any]) -> Optional[int]: # If the seed is not specified in the slot data, this mean the world was generated before Universal Tracker support. seed = slot_data.get(UNIVERSAL_TRACKER_SEED_PROPERTY) if seed is None: logger.warning(f"World was generated before Universal Tracker support. Tracker might not be accurate.") for option_name in slot_data: if option_name in self.options_dataclass.type_hints: option_value = slot_data[option_name] option_type = self.options_dataclass.type_hints[option_name] if isinstance(option_value, option_type): self.options.__setattr__(option_name, option_value) continue parsed_option_value = option_type.from_any(option_value) if isinstance(parsed_option_value, option_type): self.options.__setattr__(option_name, parsed_option_value) continue logger.warning(f"Option {option_name} was found in slot data, but could not be automatically parsed to be used in generation.\n" f"Slot Data Value: {option_value}" f"Parsed Value: {parsed_option_value}" f"Yaml Value: {self.options.__getattribute__(option_name)}") return seed def generate_early(self): force_change_options_if_banned(self.options, self.settings, self.player, self.player_name) force_change_options_if_incompatible(self.options, self.player, self.player_name) self.content = create_content(self.options) def create_regions(self): def create_region(name: str) -> Region: return Region(name, self.player, self.multiworld) world_regions = create_regions(create_region, self.options, self.content) self.logic = StardewLogic(self.player, self.options, self.content, world_regions.keys()) self.modified_bundles = get_all_bundles(self.random, self.logic, self.content, self.options, self.player_name) self.trash_bear_requests = get_trash_bear_requests(self.random, self.content, self.options) for bundle_room in self.modified_bundles: bundle_room.special_behavior(self) def add_location(name: str, code: Optional[int], region: str): assert region in world_regions, f"Location {name} cannot be created in region {region}, because the region does not exist in this slot" region: Region = world_regions[region] location = StardewLocation(self.player, name, code, region) region.locations.append(location) create_locations(add_location, self.modified_bundles, self.trash_bear_requests, self.options, self.content, self.random) self.multiworld.regions.extend(world_regions.values()) def create_items(self): self.precollect_start_inventory_items_if_needed() self.precollect_start_without_items() self.precollect_starting_season() self.precollect_building_items() self.precollect_starting_backpacks() items_to_exclude = [excluded_items for excluded_items in self.multiworld.precollected_items[self.player] if item_table[excluded_items.name].has_any_group(Group.MAXIMUM_ONE) or not item_table[excluded_items.name].has_any_group(*FILLER_GROUPS, Group.FRIENDSHIP_PACK)] if self.options.season_randomization == SeasonRandomization.option_disabled: items_to_exclude = [item for item in items_to_exclude if item_table[item.name] not in items_by_group[Group.SEASON]] locations_count = len([location for location in self.multiworld.get_locations(self.player) if location.address is not None]) created_items = create_items(self.create_item, locations_count, items_to_exclude, self.options, self.content, self.random) self.multiworld.itempool += created_items setup_early_items(self.multiworld, self.options, self.content, self.player, self.random) self.setup_logic_events() self.setup_victory() # This is really a best-effort to get the total progression items count. It is mostly used to spread grinds across spheres are push back locations that # only become available after months or years in game. In most cases, not having the exact count will not impact the logic. # # The actual total can be impacted by the start_inventory_from_pool, when items are removed from the pool but not from the total. The is also a bug # with plando where additional progression items can be created without being accounted for, which impact the real amount of progression items. This can # ultimately create unwinnable seeds where some items (like Blueberry seeds) are locked in Shipsanity: Blueberry, but world is deemed winnable as the # winning rule only check the count of collected progression items. self.total_progression_items += sum(1 for i in self.multiworld.precollected_items[self.player] if i.advancement) self.total_progression_items += sum(1 for i in self.multiworld.get_filled_locations(self.player) if i.advancement) self.total_progression_items += sum(1 for i in created_items if i.advancement) self.total_progression_items -= 1 # -1 for the victory event player_state = self.multiworld.state.prog_items[self.player] self.update_received_progression_percent(player_state) def precollect_start_inventory_items_if_needed(self): # The only reason this is necessary, is because in an UT context, precollected items was not filled up, and this messes with the seeded random later for item_name in self.options.start_inventory: item_count = self.options.start_inventory[item_name] precollected_count = len([precollected_item for precollected_item in self.multiworld.precollected_items[self.player] if precollected_item.name == item_name]) while precollected_count < item_count: self.multiworld.push_precollected(self.create_item(item_name)) precollected_count += 1 def precollect_start_without_items(self): if StartWithoutOptionName.landslide not in self.options.start_without: self.multiworld.push_precollected(self.create_item("Landslide Removed")) if StartWithoutOptionName.community_center not in self.options.start_without: self.multiworld.push_precollected(self.create_item("Community Center Key")) self.multiworld.push_precollected(self.create_item("Forest Magic")) self.multiworld.push_precollected(self.create_item("Wizard Invitation")) if StartWithoutOptionName.buildings not in self.options.start_without: self.multiworld.push_precollected(self.create_item("Shipping Bin")) self.multiworld.push_precollected(self.create_item("Pet Bowl")) def precollect_starting_season(self): if self.options.season_randomization == SeasonRandomization.option_progressive: return season_pool = items_by_group[Group.SEASON] if self.options.season_randomization == SeasonRandomization.option_disabled: for season in season_pool: self.multiworld.push_precollected(self.create_item(season)) return if [item for item in self.multiworld.precollected_items[self.player] if item.name in {season.name for season in items_by_group[Group.SEASON]}]: return if self.options.season_randomization == SeasonRandomization.option_randomized_not_winter: season_pool = [season for season in season_pool if season.name != "Winter"] starting_season = self.create_item(self.random.choice(season_pool)) self.multiworld.push_precollected(starting_season) def precollect_building_items(self): building_progression = self.content.features.building_progression # Not adding items when building are vanilla because the buildings are already placed in the world. if not building_progression.is_progressive: return # starting_buildings is a set, so sort for deterministic order. for building in sorted(building_progression.starting_buildings): item, quantity = building_progression.to_progressive_item(building) for _ in range(quantity): self.multiworld.push_precollected(self.create_item(item)) def precollect_starting_backpacks(self): if self.options.backpack_progression != BackpackProgression.option_vanilla and StartWithoutOptionName.backpack in self.options.start_without: minimum_start_slots = 4 if StartWithoutOptionName.tools in self.options.start_without else 6 num_starting_slots = max(minimum_start_slots, self.options.backpack_size.value) num_starting_backpacks = math.ceil(num_starting_slots / self.options.backpack_size.value) num_already_starting_backpacks = 0 for precollected_item in self.multiworld.precollected_items[self.player]: if precollected_item.name == "Progressive Backpack": num_already_starting_backpacks += 1 for i in range(num_starting_backpacks - num_already_starting_backpacks): self.multiworld.push_precollected(self.create_item("Progressive Backpack")) def setup_logic_events(self): def register_event(name: str, region: str, rule: StardewRule, location_name: str | None = None) -> None: if location_name is None: location_name = name event_location = LocationData(None, region, location_name) self.create_event_location(event_location, rule, name) self.logic.setup_events(register_event) def setup_victory(self): if self.options.goal == Goal.option_community_center: self.create_event_location(location_table[GoalName.community_center], self.logic.goal.can_complete_community_center(), Event.victory) elif self.options.goal == Goal.option_grandpa_evaluation: self.create_event_location(location_table[GoalName.grandpa_evaluation], self.logic.goal.can_finish_grandpa_evaluation(), Event.victory) elif self.options.goal == Goal.option_bottom_of_the_mines: self.create_event_location(location_table[GoalName.bottom_of_the_mines], self.logic.goal.can_complete_bottom_of_the_mines(), Event.victory) elif self.options.goal == Goal.option_cryptic_note: self.create_event_location(location_table[GoalName.cryptic_note], self.logic.goal.can_complete_cryptic_note(), Event.victory) elif self.options.goal == Goal.option_master_angler: self.create_event_location(location_table[GoalName.master_angler], self.logic.goal.can_complete_master_angler(), Event.victory) elif self.options.goal == Goal.option_complete_collection: self.create_event_location(location_table[GoalName.complete_museum], self.logic.goal.can_complete_complete_collection(), Event.victory) elif self.options.goal == Goal.option_full_house: self.create_event_location(location_table[GoalName.full_house], self.logic.goal.can_complete_full_house(), Event.victory) elif self.options.goal == Goal.option_greatest_walnut_hunter: self.create_event_location(location_table[GoalName.greatest_walnut_hunter], self.logic.goal.can_complete_greatest_walnut_hunter(), Event.victory) elif self.options.goal == Goal.option_protector_of_the_valley: self.create_event_location(location_table[GoalName.protector_of_the_valley], self.logic.goal.can_complete_protector_of_the_valley(), Event.victory) elif self.options.goal == Goal.option_full_shipment: self.create_event_location(location_table[GoalName.full_shipment], self.logic.goal.can_complete_full_shipment(self.get_all_location_names()), Event.victory) elif self.options.goal == Goal.option_gourmet_chef: self.create_event_location(location_table[GoalName.gourmet_chef], self.logic.goal.can_complete_gourmet_chef(), Event.victory) elif self.options.goal == Goal.option_craft_master: self.create_event_location(location_table[GoalName.craft_master], self.logic.goal.can_complete_craft_master(), Event.victory) elif self.options.goal == Goal.option_legend: self.create_event_location(location_table[GoalName.legend], self.logic.goal.can_complete_legend(), Event.victory) elif self.options.goal == Goal.option_mystery_of_the_stardrops: self.create_event_location(location_table[GoalName.mystery_of_the_stardrops], self.logic.goal.can_complete_mystery_of_the_stardrop(), Event.victory) elif self.options.goal == Goal.option_mad_hatter: self.create_event_location(location_table[GoalName.mad_hatter], self.logic.goal.can_complete_mad_hatter(self.get_all_location_names()), Event.victory) elif self.options.goal == Goal.option_ultimate_foodie: self.create_event_location(location_table[GoalName.ultimate_foodie], self.logic.goal.can_complete_ultimate_foodie(self.get_all_location_names()), Event.victory) elif self.options.goal == Goal.option_allsanity: self.create_event_location(location_table[GoalName.allsanity], self.logic.goal.can_complete_allsanity(), Event.victory) elif self.options.goal == Goal.option_perfection: self.create_event_location(location_table[GoalName.perfection], self.logic.goal.can_complete_perfection(), Event.victory) self.multiworld.completion_condition[self.player] = lambda state: state.has(Event.victory, self.player) def get_all_location_names(self) -> List[str]: return list(location.name for location in self.multiworld.get_locations(self.player)) def create_item(self, item: str | ItemData, classification_pre_fill: ItemClassification = None, classification_post_fill: ItemClassification = None) -> StardewItem: if isinstance(item, str): item = item_table[item] if classification_pre_fill is None: classification_pre_fill = item.classification stardew_item = StardewItem(item.name, classification_pre_fill, item.code, self.player) if stardew_item.advancement: # Progress is only counted for pre-fill progression items, so we don't count filler items later converted to progression post-fill. stardew_item.events_to_collect[Event.received_progression_item] = 1 if (walnut_amount := get_walnut_amount(stardew_item.name)) > 0: stardew_item.events_to_collect[Event.received_walnuts] = walnut_amount if (qi_gem_amount := get_qi_gem_amount(stardew_item.name)) > 0: stardew_item.events_to_collect[Event.received_qi_gems] = qi_gem_amount if classification_post_fill is not None: self.classifications_to_override_post_fill.append((stardew_item, classification_post_fill)) return stardew_item def create_event_location(self, location_data: LocationData, rule: StardewRule, item: str): region = self.multiworld.get_region(location_data.region, self.player) item = typing.cast(StardewItem, region.add_event(location_data.name, item, rule, StardewLocation, StardewItem)) item.events_to_collect[Event.received_progression_item] = 1 def set_rules(self): set_rules(self) def connect_entrances(self) -> None: no_target_groups = {0: [0]} placement = entrance_rando.randomize_entrances(self, coupled=True, target_group_lookup=no_target_groups) self.randomized_entrances = prepare_mod_data(placement) def generate_basic(self): pass def post_fill(self) -> None: # Not updating the prog item count, as any change could make some locations inaccessible, which is pretty much illegal in post fill. for item, classification in self.classifications_to_override_post_fill: item.classification = classification def get_filler_item_name(self) -> str: if not self.filler_item_pool_names: self.filler_item_pool_names = generate_filler_choice_pool(self.options, self.content) return self.random.choice(self.filler_item_pool_names) def write_spoiler_header(self, spoiler_handle: TextIO) -> None: """Write to the spoiler header. If individual it's right at the end of that player's options, if as stage it's right under the common header before per-player options.""" self.add_entrances_to_spoiler_log() def write_spoiler(self, spoiler_handle: TextIO) -> None: """Write to the spoiler "middle", this is after the per-player options and before locations, meant for useful or interesting info.""" self.add_bundles_to_spoiler_log(spoiler_handle) def add_bundles_to_spoiler_log(self, spoiler_handle: TextIO): if self.options.bundle_randomization == BundleRandomization.option_vanilla: return player_name = self.multiworld.get_player_name(self.player) spoiler_handle.write(f"\n\nCommunity Center ({player_name}):\n") for room in self.modified_bundles: for bundle in room.bundles: spoiler_handle.write(f"\t[{room.name}] {bundle.name} ({bundle.number_required} required):\n") for i, item in enumerate(bundle.items): if "Basic" in item.quality: quality = "" else: quality = f" ({item.quality.split(' ')[0]})" spoiler_handle.write(f"\t\t{item.amount}x {item.get_item()}{quality}\n") def add_entrances_to_spoiler_log(self): if self.options.entrance_randomization == EntranceRandomization.option_disabled: return for original_entrance, replaced_entrance in self.randomized_entrances.items(): self.multiworld.spoiler.set_entrance(original_entrance, replaced_entrance, "entrance", self.player) def fill_slot_data(self) -> Dict[str, Any]: bundles = dict() for room in self.modified_bundles: bundles[room.name] = dict() for bundle in room.bundles: bundles[room.name][bundle.name] = {"number_required": bundle.number_required} for i, item in enumerate(bundle.items): bundles[room.name][bundle.name][str(i)] = f"{item.get_item()}|{item.amount}|{item.quality}" excluded_options = [BundleRandomization, BundlePerRoom, NumberOfMovementBuffs, EnabledFillerBuffs, TrapDistribution, BundleWhitelist, BundleBlacklist, JojaAreYouSure] excluded_option_names = [option.internal_name for option in excluded_options] generic_option_names = [option_name for option_name in PerGameCommonOptions.type_hints] excluded_option_names.extend(generic_option_names) included_option_names: List[str] = [option_name for option_name in self.options_dataclass.type_hints if option_name not in excluded_option_names] slot_data = self.options.as_dict(*included_option_names) slot_data.update({ UNIVERSAL_TRACKER_SEED_PROPERTY: self.seed, "seed": self.random.randrange(1000000000), # Seed should be max 9 digits "randomized_entrances": self.randomized_entrances, "trash_bear_requests": self.trash_bear_requests, "modified_bundles": bundles, "client_version": self.world_version.as_simple_string(), }) return slot_data def collect(self, state: CollectionState, item: StardewItem) -> bool: change = super().collect(state, item) if not change: return False player_state = state.prog_items[self.player] player_state.update(item.events_to_collect) self.update_received_progression_percent(player_state) if item.name in APWeapon.all_weapons: player_state[Event.received_progressive_weapon] = max(player_state[Event.received_progressive_weapon], player_state[item.name]) return True def remove(self, state: CollectionState, item: StardewItem) -> bool: change = super().remove(state, item) if not change: return False player_state = state.prog_items[self.player] player_state.subtract(item.events_to_collect) self.update_received_progression_percent(player_state) if item.name in APWeapon.all_weapons: player_state[Event.received_progressive_weapon] = max(player_state[weapon] for weapon in APWeapon.all_weapons) return True def update_received_progression_percent(self, player_state: Counter[str]) -> None: if self.total_progression_items: received_progression_count = player_state[Event.received_progression_item] # Total progression items is not set until all items are created, but collect will be called during the item creation when an item is precollected. # We can't update the percentage if we don't know the total progression items, can't divide by 0. player_state[Event.received_progression_percent] = received_progression_count * 100 // self.total_progression_items