Files
dockipelago/worlds/stardew_valley/__init__.py
agilbert1412 1de91fab67 Stardew Valley: 7.x.x - The Jojapocalypse Update (#5432)
Major Content update for Stardew Valley

### Features
- New BundleRandomization Value: Meme Bundles - Over 100 custom bundles, designed to be jokes, references, trolls, etc
- New Setting: Bundles Per Room modifier
- New Setting: Backpack Size
- New Setting: Secretsanity - Checks for triggering easter eggs and secrets
- New Setting: Moviesanity - Checks for watching movies and sharing snacks with Villagers
- New Setting: Eatsanity - Checks for eating items
- New Setting: Hatsanity - Checks for wearing Hats
- New Setting: Start Without - Allows you to select any combination of various "starting" items, that you will actually not start with. Notably, tools, backpack slots, Day5 unlocks, etc.
- New Setting: Allowed Filler Items - Allows you to customize the filler items you'll get
- New Setting: Endgame Locations - Checks for various expensive endgame tasks and purchases
- New Shipsanity value: Crops and Fish
- New Settings: Jojapocalypse and settings to customize it
- Bundle Plando: Replaced with BundleWhitelist and BundleBlacklist, for more customization freedom
- Added a couple of Host.yaml settings to help hosts allow or ban specific difficult settings that could cause problems if the people don't know what they are signing up for.

Plus a truckload of improvements on the mod side, not seen in this PR.

### Removed features
- Integration for Stardew Valley Expanded. It is simply disabled, the code is all still there, but I'm extremely tired of providing tech support for it, plus Stardew Valley 1.7 was announced and that will break it again, so I'm done. When a maintainer steps up, it can be re-enabled.
2026-02-15 18:02:21 +01:00

564 lines
29 KiB
Python

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