import logging import os import threading import unittest from contextlib import contextmanager from typing import Dict, ClassVar, Iterable, Tuple, Optional, List, Union, Any from BaseClasses import MultiWorld, CollectionState, get_seed, Location, Item from test.bases import WorldTestBase from test.general import gen_steps, setup_solo_multiworld as setup_base_solo_multiworld from worlds.AutoWorld import call_all from .assertion import RuleAssertMixin from .options.utils import fill_namespace_with_default, parse_class_option_keys, fill_dataclass_with_default from .. import StardewValleyWorld, options, StardewItem from ..options import StardewValleyOption logger = logging.getLogger(__name__) DEFAULT_TEST_SEED = get_seed() logger.info(f"Default Test Seed: {DEFAULT_TEST_SEED}") def default_6_x_x(): return { options.ArcadeMachineLocations.internal_name: options.ArcadeMachineLocations.default, options.BackpackProgression.internal_name: options.BackpackProgression.default, options.Booksanity.internal_name: options.Booksanity.default, options.BuildingProgression.internal_name: options.BuildingProgression.default, options.BundlePrice.internal_name: options.BundlePrice.default, options.BundleRandomization.internal_name: options.BundleRandomization.default, options.Chefsanity.internal_name: options.Chefsanity.default, options.Cooksanity.internal_name: options.Cooksanity.default, options.Craftsanity.internal_name: options.Craftsanity.default, options.Cropsanity.internal_name: options.Cropsanity.default, options.ElevatorProgression.internal_name: options.ElevatorProgression.default, options.EntranceRandomization.internal_name: options.EntranceRandomization.default, options.ExcludeGingerIsland.internal_name: options.ExcludeGingerIsland.default, options.FestivalLocations.internal_name: options.FestivalLocations.default, options.Fishsanity.internal_name: options.Fishsanity.default, options.Friendsanity.internal_name: options.Friendsanity.default, options.FriendsanityHeartSize.internal_name: options.FriendsanityHeartSize.default, options.Goal.internal_name: options.Goal.default, options.Mods.internal_name: options.Mods.default, options.Monstersanity.internal_name: options.Monstersanity.default, options.Museumsanity.internal_name: options.Museumsanity.default, options.NumberOfMovementBuffs.internal_name: options.NumberOfMovementBuffs.default, options.EnabledFillerBuffs.internal_name: options.EnabledFillerBuffs.default, options.QuestLocations.internal_name: options.QuestLocations.default, options.SeasonRandomization.internal_name: options.SeasonRandomization.default, options.Shipsanity.internal_name: options.Shipsanity.default, options.SkillProgression.internal_name: options.SkillProgression.default, options.SpecialOrderLocations.internal_name: options.SpecialOrderLocations.default, options.ToolProgression.internal_name: options.ToolProgression.default, options.TrapItems.internal_name: options.TrapItems.default, options.Walnutsanity.internal_name: options.Walnutsanity.default } def allsanity_no_mods_6_x_x(): return { options.ArcadeMachineLocations.internal_name: options.ArcadeMachineLocations.option_full_shuffling, options.BackpackProgression.internal_name: options.BackpackProgression.option_progressive, options.Booksanity.internal_name: options.Booksanity.option_all, options.BuildingProgression.internal_name: options.BuildingProgression.option_progressive, options.BundlePrice.internal_name: options.BundlePrice.option_expensive, options.BundleRandomization.internal_name: options.BundleRandomization.option_thematic, options.Chefsanity.internal_name: options.Chefsanity.option_all, options.Cooksanity.internal_name: options.Cooksanity.option_all, options.Craftsanity.internal_name: options.Craftsanity.option_all, options.Cropsanity.internal_name: options.Cropsanity.option_enabled, options.ElevatorProgression.internal_name: options.ElevatorProgression.option_progressive, options.EntranceRandomization.internal_name: options.EntranceRandomization.option_disabled, options.ExcludeGingerIsland.internal_name: options.ExcludeGingerIsland.option_false, options.FestivalLocations.internal_name: options.FestivalLocations.option_hard, options.Fishsanity.internal_name: options.Fishsanity.option_all, options.Friendsanity.internal_name: options.Friendsanity.option_all_with_marriage, options.FriendsanityHeartSize.internal_name: 1, options.Goal.internal_name: options.Goal.option_perfection, options.Mods.internal_name: frozenset(), options.Monstersanity.internal_name: options.Monstersanity.option_progressive_goals, options.Museumsanity.internal_name: options.Museumsanity.option_all, options.EnabledFillerBuffs.internal_name: options.EnabledFillerBuffs.preset_all, options.NumberOfMovementBuffs.internal_name: 12, options.QuestLocations.internal_name: 56, options.SeasonRandomization.internal_name: options.SeasonRandomization.option_randomized, options.Shipsanity.internal_name: options.Shipsanity.option_everything, options.SkillProgression.internal_name: options.SkillProgression.option_progressive_with_masteries, options.SpecialOrderLocations.internal_name: options.SpecialOrderLocations.option_board_qi, options.ToolProgression.internal_name: options.ToolProgression.option_progressive, options.TrapItems.internal_name: options.TrapItems.option_nightmare, options.Walnutsanity.internal_name: options.Walnutsanity.preset_all } def allsanity_mods_6_x_x(): allsanity = allsanity_no_mods_6_x_x() allsanity.update({options.Mods.internal_name: frozenset(options.Mods.valid_keys)}) return allsanity def get_minsanity_options(): return { options.ArcadeMachineLocations.internal_name: options.ArcadeMachineLocations.option_disabled, options.BackpackProgression.internal_name: options.BackpackProgression.option_vanilla, options.Booksanity.internal_name: options.Booksanity.option_none, options.BuildingProgression.internal_name: options.BuildingProgression.option_vanilla, options.BundlePrice.internal_name: options.BundlePrice.option_very_cheap, options.BundleRandomization.internal_name: options.BundleRandomization.option_vanilla, options.Chefsanity.internal_name: options.Chefsanity.option_none, options.Cooksanity.internal_name: options.Cooksanity.option_none, options.Craftsanity.internal_name: options.Craftsanity.option_none, options.Cropsanity.internal_name: options.Cropsanity.option_disabled, options.ElevatorProgression.internal_name: options.ElevatorProgression.option_vanilla, options.EntranceRandomization.internal_name: options.EntranceRandomization.option_disabled, options.ExcludeGingerIsland.internal_name: options.ExcludeGingerIsland.option_true, options.FestivalLocations.internal_name: options.FestivalLocations.option_disabled, options.Fishsanity.internal_name: options.Fishsanity.option_none, options.Friendsanity.internal_name: options.Friendsanity.option_none, options.FriendsanityHeartSize.internal_name: 8, options.Goal.internal_name: options.Goal.option_bottom_of_the_mines, options.Mods.internal_name: frozenset(), options.Monstersanity.internal_name: options.Monstersanity.option_none, options.Museumsanity.internal_name: options.Museumsanity.option_none, options.EnabledFillerBuffs.internal_name: options.EnabledFillerBuffs.preset_none, options.NumberOfMovementBuffs.internal_name: 0, options.QuestLocations.internal_name: -1, options.SeasonRandomization.internal_name: options.SeasonRandomization.option_disabled, options.Shipsanity.internal_name: options.Shipsanity.option_none, options.SkillProgression.internal_name: options.SkillProgression.option_vanilla, options.SpecialOrderLocations.internal_name: options.SpecialOrderLocations.option_vanilla, options.ToolProgression.internal_name: options.ToolProgression.option_vanilla, options.TrapItems.internal_name: options.TrapItems.option_no_traps, options.Walnutsanity.internal_name: options.Walnutsanity.preset_none } def minimal_locations_maximal_items(): min_max_options = { options.ArcadeMachineLocations.internal_name: options.ArcadeMachineLocations.option_disabled, options.BackpackProgression.internal_name: options.BackpackProgression.option_vanilla, options.Booksanity.internal_name: options.Booksanity.option_none, options.BuildingProgression.internal_name: options.BuildingProgression.option_vanilla, options.BundlePrice.internal_name: options.BundlePrice.option_expensive, options.BundleRandomization.internal_name: options.BundleRandomization.option_shuffled, options.Chefsanity.internal_name: options.Chefsanity.option_none, options.Cooksanity.internal_name: options.Cooksanity.option_none, options.Craftsanity.internal_name: options.Craftsanity.option_none, options.Cropsanity.internal_name: options.Cropsanity.option_disabled, options.ElevatorProgression.internal_name: options.ElevatorProgression.option_vanilla, options.EntranceRandomization.internal_name: options.EntranceRandomization.option_disabled, options.ExcludeGingerIsland.internal_name: options.ExcludeGingerIsland.option_true, options.FestivalLocations.internal_name: options.FestivalLocations.option_disabled, options.Fishsanity.internal_name: options.Fishsanity.option_none, options.Friendsanity.internal_name: options.Friendsanity.option_none, options.FriendsanityHeartSize.internal_name: 8, options.Goal.internal_name: options.Goal.option_craft_master, options.Mods.internal_name: frozenset(), options.Monstersanity.internal_name: options.Monstersanity.option_none, options.Museumsanity.internal_name: options.Museumsanity.option_none, options.EnabledFillerBuffs.internal_name: options.EnabledFillerBuffs.preset_all, options.NumberOfMovementBuffs.internal_name: 12, options.QuestLocations.internal_name: -1, options.SeasonRandomization.internal_name: options.SeasonRandomization.option_randomized, options.Shipsanity.internal_name: options.Shipsanity.option_none, options.SkillProgression.internal_name: options.SkillProgression.option_vanilla, options.SpecialOrderLocations.internal_name: options.SpecialOrderLocations.option_vanilla, options.ToolProgression.internal_name: options.ToolProgression.option_vanilla, options.TrapItems.internal_name: options.TrapItems.option_nightmare, options.Walnutsanity.internal_name: options.Walnutsanity.preset_none } return min_max_options def minimal_locations_maximal_items_with_island(): min_max_options = minimal_locations_maximal_items() min_max_options.update({options.ExcludeGingerIsland.internal_name: options.ExcludeGingerIsland.option_false}) return min_max_options class SVTestCase(unittest.TestCase): # Set False to not skip some 'extra' tests skip_base_tests: bool = True # Set False to run tests that take long skip_long_tests: bool = True @classmethod def setUpClass(cls) -> None: super().setUpClass() base_tests_key = "base" if base_tests_key in os.environ: cls.skip_base_tests = not bool(os.environ[base_tests_key]) long_tests_key = "long" if long_tests_key in os.environ: cls.skip_long_tests = not bool(os.environ[long_tests_key]) @contextmanager def solo_world_sub_test(self, msg: Optional[str] = None, /, world_options: Optional[Dict[Union[str, StardewValleyOption], Any]] = None, *, seed=DEFAULT_TEST_SEED, world_caching=True, **kwargs) -> Tuple[MultiWorld, StardewValleyWorld]: if msg is not None: msg += " " else: msg = "" msg += f"[Seed = {seed}]" with self.subTest(msg, **kwargs): with solo_multiworld(world_options, seed=seed, world_caching=world_caching) as (multiworld, world): yield multiworld, world class SVTestBase(RuleAssertMixin, WorldTestBase, SVTestCase): game = "Stardew Valley" world: StardewValleyWorld player: ClassVar[int] = 1 seed = DEFAULT_TEST_SEED @classmethod def setUpClass(cls) -> None: if cls is SVTestBase: raise unittest.SkipTest("No running tests on SVTestBase import.") super().setUpClass() def world_setup(self, *args, **kwargs): self.options = parse_class_option_keys(self.options) self.multiworld = setup_solo_multiworld(self.options, seed=self.seed) self.multiworld.lock.acquire() world = self.multiworld.worlds[self.player] self.original_state = self.multiworld.state.copy() self.original_itempool = self.multiworld.itempool.copy() self.unfilled_locations = self.multiworld.get_unfilled_locations(1) if self.constructed: self.world = world # noqa def tearDown(self) -> None: self.multiworld.state = self.original_state self.multiworld.itempool = self.original_itempool for location in self.unfilled_locations: location.item = None self.multiworld.lock.release() @property def run_default_tests(self) -> bool: if self.skip_base_tests: return False return super().run_default_tests def collect_lots_of_money(self, percent: float = 0.25): self.collect("Shipping Bin") real_total_prog_items = self.world.total_progression_items required_prog_items = int(round(real_total_prog_items * percent)) self.collect("Stardrop", required_prog_items) def collect_all_the_money(self): self.collect_lots_of_money(0.95) def collect_everything(self): non_event_items = [item for item in self.multiworld.get_items() if item.code] for item in non_event_items: self.multiworld.state.collect(item) def collect_all_except(self, item_to_not_collect: str): non_event_items = [item for item in self.multiworld.get_items() if item.code] for item in non_event_items: if item.name != item_to_not_collect: self.multiworld.state.collect(item) def get_real_locations(self) -> List[Location]: return [location for location in self.multiworld.get_locations(self.player) if location.address is not None] def get_real_location_names(self) -> List[str]: return [location.name for location in self.get_real_locations()] def collect(self, item: Union[str, Item, Iterable[Item]], count: int = 1) -> Union[None, Item, List[Item]]: assert count > 0 if not isinstance(item, str): super().collect(item) return if count == 1: item = self.create_item(item) self.multiworld.state.collect(item) return item items = [] for i in range(count): item = self.create_item(item) self.multiworld.state.collect(item) items.append(item) return items def create_item(self, item: str) -> StardewItem: return self.world.create_item(item) def remove_one_by_name(self, item: str) -> None: self.remove(self.create_item(item)) def reset_collection_state(self): self.multiworld.state = self.original_state.copy() pre_generated_worlds = {} @contextmanager def solo_multiworld(world_options: Optional[Dict[Union[str, StardewValleyOption], Any]] = None, *, seed=DEFAULT_TEST_SEED, world_caching=True) -> Tuple[MultiWorld, StardewValleyWorld]: if not world_caching: multiworld = setup_solo_multiworld(world_options, seed, _cache={}) yield multiworld, multiworld.worlds[1] else: multiworld = setup_solo_multiworld(world_options, seed) multiworld.lock.acquire() world = multiworld.worlds[1] original_state = multiworld.state.copy() original_itempool = multiworld.itempool.copy() unfilled_locations = multiworld.get_unfilled_locations(1) yield multiworld, world multiworld.state = original_state multiworld.itempool = original_itempool for location in unfilled_locations: location.item = None multiworld.lock.release() # Mostly a copy of test.general.setup_solo_multiworld, I just don't want to change the core. def setup_solo_multiworld(test_options: Optional[Dict[Union[str, StardewValleyOption], str]] = None, seed=DEFAULT_TEST_SEED, _cache: Dict[frozenset, MultiWorld] = {}, # noqa _steps=gen_steps) -> MultiWorld: test_options = parse_class_option_keys(test_options) # Yes I reuse the worlds generated between tests, its speeds the execution by a couple seconds # If the simple dict caching ends up taking too much memory, we could replace it with some kind of lru cache. should_cache = "start_inventory" not in test_options if should_cache: frozen_options = frozenset(test_options.items()).union({("seed", seed)}) cached_multi_world = search_world_cache(_cache, frozen_options) if cached_multi_world: print(f"Using cached solo multi world [Seed = {cached_multi_world.seed}] [Cache size = {len(_cache)}]") return cached_multi_world multiworld = setup_base_solo_multiworld(StardewValleyWorld, (), seed=seed) # print(f"Seed: {multiworld.seed}") # Uncomment to print the seed for every test args = fill_namespace_with_default(test_options) multiworld.set_options(args) if "start_inventory" in test_options: for item, amount in test_options["start_inventory"].items(): for _ in range(amount): multiworld.push_precollected(multiworld.create_item(item, 1)) for step in _steps: call_all(multiworld, step) if should_cache: add_to_world_cache(_cache, frozen_options, multiworld) # noqa # Lock is needed for multi-threading tests setattr(multiworld, "lock", threading.Lock()) return multiworld def search_world_cache(cache: Dict[frozenset, MultiWorld], frozen_options: frozenset) -> Optional[MultiWorld]: try: return cache[frozen_options] except KeyError: for cached_options, multi_world in cache.items(): if frozen_options.issubset(cached_options): return multi_world return None def add_to_world_cache(cache: Dict[frozenset, MultiWorld], frozen_options: frozenset, multi_world: MultiWorld) -> None: # We could complete the key with all the default options, but that does not seem to improve performances. cache[frozen_options] = multi_world def setup_multiworld(test_options: Iterable[Dict[str, int]] = None, seed=None) -> MultiWorld: # noqa if test_options is None: test_options = [] multiworld = MultiWorld(len(test_options)) multiworld.player_name = {} multiworld.set_seed(seed) multiworld.state = CollectionState(multiworld) for i in range(1, len(test_options) + 1): multiworld.game[i] = StardewValleyWorld.game multiworld.player_name.update({i: f"Tester{i}"}) args = fill_namespace_with_default(test_options) multiworld.set_options(args) for step in gen_steps: call_all(multiworld, step) return multiworld