diff --git a/worlds/stardew_valley/__init__.py b/worlds/stardew_valley/__init__.py index f48c9bc1..9a05c04d 100644 --- a/worlds/stardew_valley/__init__.py +++ b/worlds/stardew_valley/__init__.py @@ -10,11 +10,13 @@ from .bundles.bundle_room import BundleRoom from .bundles.bundles import get_all_bundles from .content import StardewContent, create_content from .early_items import setup_early_items -from .items import item_table, create_items, ItemData, Group, items_by_group, generate_filler_choice_pool +from .items import item_table, ItemData, Group, items_by_group +from .items.item_creation import create_items, get_all_filler_items, remove_limited_amount_packs, \ + generate_filler_choice_pool from .locations import location_table, create_locations, LocationData, locations_by_tag from .logic.logic import StardewLogic -from .options import StardewValleyOptions, SeasonRandomization, Goal, BundleRandomization, EnabledFillerBuffs, NumberOfMovementBuffs, \ - BuildingProgression, EntranceRandomization, FarmType +from .options import StardewValleyOptions, SeasonRandomization, Goal, BundleRandomization, EnabledFillerBuffs, \ + NumberOfMovementBuffs, BuildingProgression, EntranceRandomization, FarmType from .options.forced_options import force_change_options_if_incompatible from .options.option_groups import sv_option_groups from .options.presets import sv_options_presets diff --git a/worlds/stardew_valley/items/__init__.py b/worlds/stardew_valley/items/__init__.py new file mode 100644 index 00000000..ddf5e69f --- /dev/null +++ b/worlds/stardew_valley/items/__init__.py @@ -0,0 +1 @@ +from .item_data import item_table, ItemData, Group, items_by_group, load_item_csv diff --git a/worlds/stardew_valley/items.py b/worlds/stardew_valley/items/item_creation.py similarity index 86% rename from worlds/stardew_valley/items.py rename to worlds/stardew_valley/items/item_creation.py index a0f901a2..6928ca8b 100644 --- a/worlds/stardew_valley/items.py +++ b/worlds/stardew_valley/items/item_creation.py @@ -1,165 +1,26 @@ -import csv -import enum import logging -from dataclasses import dataclass, field -from functools import reduce -from pathlib import Path from random import Random -from typing import Dict, List, Protocol, Union, Set, Optional +from typing import List, Set from BaseClasses import Item, ItemClassification -from . import data -from .content.feature import friendsanity -from .content.game_content import StardewContent -from .data.game_item import ItemTag -from .logic.logic_event import all_events -from .mods.mod_data import ModNames -from .options import StardewValleyOptions, TrapItems, FestivalLocations, ExcludeGingerIsland, SpecialOrderLocations, SeasonRandomization, Museumsanity, \ +from .item_data import StardewItemFactory, items_by_group, Group, item_table, ItemData +from ..content.feature import friendsanity +from ..content.game_content import StardewContent +from ..data.game_item import ItemTag +from ..mods.mod_data import ModNames +from ..options import StardewValleyOptions, FestivalLocations, ExcludeGingerIsland, SpecialOrderLocations, SeasonRandomization, Museumsanity, \ ElevatorProgression, BackpackProgression, ArcadeMachineLocations, Monstersanity, Goal, \ - Chefsanity, Craftsanity, BundleRandomization, EntranceRandomization, Shipsanity, Walnutsanity, EnabledFillerBuffs -from .strings.ap_names.ap_option_names import BuffOptionName, WalnutsanityOptionName -from .strings.ap_names.ap_weapon_names import APWeapon -from .strings.ap_names.buff_names import Buff -from .strings.ap_names.community_upgrade_names import CommunityUpgrade -from .strings.ap_names.mods.mod_items import SVEQuestItem -from .strings.currency_names import Currency -from .strings.tool_names import Tool -from .strings.wallet_item_names import Wallet - -ITEM_CODE_OFFSET = 717000 + Chefsanity, Craftsanity, BundleRandomization, EntranceRandomization, Shipsanity, Walnutsanity, EnabledFillerBuffs, TrapDifficulty +from ..strings.ap_names.ap_option_names import BuffOptionName, WalnutsanityOptionName +from ..strings.ap_names.ap_weapon_names import APWeapon +from ..strings.ap_names.buff_names import Buff +from ..strings.ap_names.community_upgrade_names import CommunityUpgrade +from ..strings.ap_names.mods.mod_items import SVEQuestItem +from ..strings.currency_names import Currency +from ..strings.tool_names import Tool +from ..strings.wallet_item_names import Wallet logger = logging.getLogger(__name__) -world_folder = Path(__file__).parent - - -class Group(enum.Enum): - RESOURCE_PACK = enum.auto() - FRIENDSHIP_PACK = enum.auto() - COMMUNITY_REWARD = enum.auto() - TRASH = enum.auto() - FOOTWEAR = enum.auto() - HATS = enum.auto() - RING = enum.auto() - WEAPON = enum.auto() - WEAPON_GENERIC = enum.auto() - WEAPON_SWORD = enum.auto() - WEAPON_CLUB = enum.auto() - WEAPON_DAGGER = enum.auto() - WEAPON_SLINGSHOT = enum.auto() - PROGRESSIVE_TOOLS = enum.auto() - SKILL_LEVEL_UP = enum.auto() - SKILL_MASTERY = enum.auto() - BUILDING = enum.auto() - WIZARD_BUILDING = enum.auto() - ARCADE_MACHINE_BUFFS = enum.auto() - BASE_RESOURCE = enum.auto() - WARP_TOTEM = enum.auto() - GEODE = enum.auto() - ORE = enum.auto() - FERTILIZER = enum.auto() - SEED = enum.auto() - CROPSANITY = enum.auto() - FISHING_RESOURCE = enum.auto() - SEASON = enum.auto() - TRAVELING_MERCHANT_DAY = enum.auto() - MUSEUM = enum.auto() - FRIENDSANITY = enum.auto() - FESTIVAL = enum.auto() - RARECROW = enum.auto() - TRAP = enum.auto() - BONUS = enum.auto() - MAXIMUM_ONE = enum.auto() - AT_LEAST_TWO = enum.auto() - DEPRECATED = enum.auto() - RESOURCE_PACK_USEFUL = enum.auto() - SPECIAL_ORDER_BOARD = enum.auto() - SPECIAL_ORDER_QI = enum.auto() - BABY = enum.auto() - GINGER_ISLAND = enum.auto() - WALNUT_PURCHASE = enum.auto() - TV_CHANNEL = enum.auto() - QI_CRAFTING_RECIPE = enum.auto() - CHEFSANITY = enum.auto() - CHEFSANITY_STARTER = enum.auto() - CHEFSANITY_QOS = enum.auto() - CHEFSANITY_PURCHASE = enum.auto() - CHEFSANITY_FRIENDSHIP = enum.auto() - CHEFSANITY_SKILL = enum.auto() - CRAFTSANITY = enum.auto() - BOOK_POWER = enum.auto() - LOST_BOOK = enum.auto() - PLAYER_BUFF = enum.auto() - # Mods - MAGIC_SPELL = enum.auto() - MOD_WARP = enum.auto() - - -@dataclass(frozen=True) -class ItemData: - code_without_offset: Optional[int] - name: str - classification: ItemClassification - mod_name: Optional[str] = None - groups: Set[Group] = field(default_factory=frozenset) - - def __post_init__(self): - if not isinstance(self.groups, frozenset): - super().__setattr__("groups", frozenset(self.groups)) - - @property - def code(self): - return ITEM_CODE_OFFSET + self.code_without_offset if self.code_without_offset is not None else None - - def has_any_group(self, *group: Group) -> bool: - groups = set(group) - return bool(groups.intersection(self.groups)) - - -class StardewItemFactory(Protocol): - def __call__(self, name: Union[str, ItemData], override_classification: ItemClassification = None) -> Item: - raise NotImplementedError - - -def load_item_csv(): - from importlib.resources import files - - items = [] - with files(data).joinpath("items.csv").open() as file: - item_reader = csv.DictReader(file) - for item in item_reader: - id = int(item["id"]) if item["id"] else None - classification = reduce((lambda a, b: a | b), {ItemClassification[str_classification] for str_classification in item["classification"].split(",")}) - groups = {Group[group] for group in item["groups"].split(",") if group} - mod_name = str(item["mod_name"]) if item["mod_name"] else None - items.append(ItemData(id, item["name"], classification, mod_name, groups)) - return items - - -events = [ - ItemData(None, e, ItemClassification.progression) - for e in sorted(all_events) -] - -all_items: List[ItemData] = load_item_csv() + events -item_table: Dict[str, ItemData] = {} -items_by_group: Dict[Group, List[ItemData]] = {} - - -def initialize_groups(): - for item in all_items: - for group in item.groups: - item_group = items_by_group.get(group, list()) - item_group.append(item) - items_by_group[group] = item_group - - -def initialize_item_table(): - item_table.update({item.name: item for item in all_items}) - - -initialize_item_table() -initialize_groups() - def get_too_many_items_error_message(locations_count: int, items_count: int) -> str: return f"There should be at least as many locations [{locations_count}] as there are mandatory items [{items_count}]" @@ -712,13 +573,15 @@ def weapons_count(options: StardewValleyOptions): def fill_with_resource_packs_and_traps(item_factory: StardewItemFactory, options: StardewValleyOptions, random: Random, items_already_added: List[Item], available_item_slots: int) -> List[Item]: - include_traps = options.trap_items != TrapItems.option_no_traps + include_traps = options.trap_difficulty != TrapDifficulty.option_no_traps items_already_added_names = [item.name for item in items_already_added] useful_resource_packs = [pack for pack in items_by_group[Group.RESOURCE_PACK_USEFUL] if pack.name not in items_already_added_names] trap_items = [trap for trap in items_by_group[Group.TRAP] if trap.name not in items_already_added_names and - (trap.mod_name is None or trap.mod_name in options.mods)] + Group.DEPRECATED not in trap.groups and + (trap.mod_name is None or trap.mod_name in options.mods) and + options.trap_distribution[trap.name] > 0] player_buffs = get_allowed_player_buffs(options.enabled_filler_buffs) priority_filler_items = [] @@ -750,11 +613,13 @@ def fill_with_resource_packs_and_traps(item_factory: StardewItemFactory, options (filler_pack.name not in [priority_item.name for priority_item in priority_filler_items] and filler_pack.name not in items_already_added_names)] + filler_weights = get_filler_weights(options, all_filler_packs) + while available_item_slots > 0: - resource_pack = random.choice(all_filler_packs) + resource_pack = random.choices(all_filler_packs, weights=filler_weights, k=1)[0] exactly_2 = Group.AT_LEAST_TWO in resource_pack.groups while exactly_2 and available_item_slots == 1: - resource_pack = random.choice(all_filler_packs) + resource_pack = random.choices(all_filler_packs, weights=filler_weights, k=1)[0] exactly_2 = Group.AT_LEAST_TWO in resource_pack.groups classification = ItemClassification.useful if resource_pack.classification == ItemClassification.progression else resource_pack.classification items.append(item_factory(resource_pack, classification)) @@ -763,11 +628,24 @@ def fill_with_resource_packs_and_traps(item_factory: StardewItemFactory, options items.append(item_factory(resource_pack, classification)) available_item_slots -= 1 if exactly_2 or Group.MAXIMUM_ONE in resource_pack.groups: - all_filler_packs.remove(resource_pack) + index = all_filler_packs.index(resource_pack) + all_filler_packs.pop(index) + filler_weights.pop(index) return items +def get_filler_weights(options: StardewValleyOptions, all_filler_packs: List[ItemData]): + weights = [] + for filler in all_filler_packs: + if filler.name in options.trap_distribution: + num = options.trap_distribution[filler.name] + else: + num = options.trap_distribution.default_weight + weights.append(num) + return weights + + def filter_deprecated_items(items: List[ItemData]) -> List[ItemData]: return [item for item in items if Group.DEPRECATED not in item.groups] @@ -792,7 +670,7 @@ def remove_excluded_items_island_mods(items, exclude_ginger_island: bool, mods: def generate_filler_choice_pool(options: StardewValleyOptions) -> list[str]: - include_traps = options.trap_items != TrapItems.option_no_traps + include_traps = options.trap_difficulty != TrapDifficulty.option_no_traps exclude_island = options.exclude_ginger_island == ExcludeGingerIsland.option_true available_filler = get_all_filler_items(include_traps, exclude_island) diff --git a/worlds/stardew_valley/items/item_data.py b/worlds/stardew_valley/items/item_data.py new file mode 100644 index 00000000..e7c3779e --- /dev/null +++ b/worlds/stardew_valley/items/item_data.py @@ -0,0 +1,143 @@ +import csv +import enum +from dataclasses import dataclass, field +from functools import reduce +from pathlib import Path +from typing import Dict, List, Protocol, Union, Set, Optional + +from BaseClasses import Item, ItemClassification +from .. import data +from ..logic.logic_event import all_events + +ITEM_CODE_OFFSET = 717000 + +world_folder = Path(__file__).parent + + +class Group(enum.Enum): + RESOURCE_PACK = enum.auto() + FRIENDSHIP_PACK = enum.auto() + COMMUNITY_REWARD = enum.auto() + TRASH = enum.auto() + FOOTWEAR = enum.auto() + HATS = enum.auto() + RING = enum.auto() + WEAPON = enum.auto() + WEAPON_GENERIC = enum.auto() + WEAPON_SWORD = enum.auto() + WEAPON_CLUB = enum.auto() + WEAPON_DAGGER = enum.auto() + WEAPON_SLINGSHOT = enum.auto() + PROGRESSIVE_TOOLS = enum.auto() + SKILL_LEVEL_UP = enum.auto() + SKILL_MASTERY = enum.auto() + BUILDING = enum.auto() + WIZARD_BUILDING = enum.auto() + ARCADE_MACHINE_BUFFS = enum.auto() + BASE_RESOURCE = enum.auto() + WARP_TOTEM = enum.auto() + GEODE = enum.auto() + ORE = enum.auto() + FERTILIZER = enum.auto() + SEED = enum.auto() + CROPSANITY = enum.auto() + FISHING_RESOURCE = enum.auto() + SEASON = enum.auto() + TRAVELING_MERCHANT_DAY = enum.auto() + MUSEUM = enum.auto() + FRIENDSANITY = enum.auto() + FESTIVAL = enum.auto() + RARECROW = enum.auto() + TRAP = enum.auto() + BONUS = enum.auto() + MAXIMUM_ONE = enum.auto() + AT_LEAST_TWO = enum.auto() + DEPRECATED = enum.auto() + RESOURCE_PACK_USEFUL = enum.auto() + SPECIAL_ORDER_BOARD = enum.auto() + SPECIAL_ORDER_QI = enum.auto() + BABY = enum.auto() + GINGER_ISLAND = enum.auto() + WALNUT_PURCHASE = enum.auto() + TV_CHANNEL = enum.auto() + QI_CRAFTING_RECIPE = enum.auto() + CHEFSANITY = enum.auto() + CHEFSANITY_STARTER = enum.auto() + CHEFSANITY_QOS = enum.auto() + CHEFSANITY_PURCHASE = enum.auto() + CHEFSANITY_FRIENDSHIP = enum.auto() + CHEFSANITY_SKILL = enum.auto() + CRAFTSANITY = enum.auto() + BOOK_POWER = enum.auto() + LOST_BOOK = enum.auto() + PLAYER_BUFF = enum.auto() + # Mods + MAGIC_SPELL = enum.auto() + MOD_WARP = enum.auto() + + +@dataclass(frozen=True) +class ItemData: + code_without_offset: Optional[int] + name: str + classification: ItemClassification + mod_name: Optional[str] = None + groups: Set[Group] = field(default_factory=frozenset) + + def __post_init__(self): + if not isinstance(self.groups, frozenset): + super().__setattr__("groups", frozenset(self.groups)) + + @property + def code(self): + return ITEM_CODE_OFFSET + self.code_without_offset if self.code_without_offset is not None else None + + def has_any_group(self, *group: Group) -> bool: + groups = set(group) + return bool(groups.intersection(self.groups)) + + +class StardewItemFactory(Protocol): + def __call__(self, name: Union[str, ItemData], override_classification: ItemClassification = None) -> Item: + raise NotImplementedError + + +def load_item_csv(): + from importlib.resources import files + + items = [] + with files(data).joinpath("items.csv").open() as file: + item_reader = csv.DictReader(file) + for item in item_reader: + id = int(item["id"]) if item["id"] else None + classification = reduce((lambda a, b: a | b), {ItemClassification[str_classification] for str_classification in item["classification"].split(",")}) + groups = {Group[group] for group in item["groups"].split(",") if group} + mod_name = str(item["mod_name"]) if item["mod_name"] else None + items.append(ItemData(id, item["name"], classification, mod_name, groups)) + return items + + +events = [ + ItemData(None, e, ItemClassification.progression) + for e in sorted(all_events) +] + +all_items: List[ItemData] = load_item_csv() + events +item_table: Dict[str, ItemData] = {} +items_by_group: Dict[Group, List[ItemData]] = {} + + +def initialize_groups(): + for item in all_items: + for group in item.groups: + item_group = items_by_group.get(group, list()) + item_group.append(item) + items_by_group[group] = item_group + + +def initialize_item_table(): + item_table.update({item.name: item for item in all_items}) + + +initialize_item_table() +initialize_groups() diff --git a/worlds/stardew_valley/options/__init__.py b/worlds/stardew_valley/options/__init__.py index 713d3e95..12c0d7c6 100644 --- a/worlds/stardew_valley/options/__init__.py +++ b/worlds/stardew_valley/options/__init__.py @@ -1,6 +1,6 @@ from .options import StardewValleyOption, Goal, FarmType, StartingMoney, ProfitMargin, BundleRandomization, BundlePrice, EntranceRandomization, \ SeasonRandomization, Cropsanity, BackpackProgression, ToolProgression, ElevatorProgression, SkillProgression, BuildingProgression, FestivalLocations, \ ArcadeMachineLocations, SpecialOrderLocations, QuestLocations, Fishsanity, Museumsanity, Monstersanity, Shipsanity, Cooksanity, Chefsanity, Craftsanity, \ - Friendsanity, FriendsanityHeartSize, Booksanity, Walnutsanity, NumberOfMovementBuffs, EnabledFillerBuffs, ExcludeGingerIsland, TrapItems, \ + Friendsanity, FriendsanityHeartSize, Booksanity, Walnutsanity, NumberOfMovementBuffs, EnabledFillerBuffs, ExcludeGingerIsland, TrapDifficulty, \ MultipleDaySleepEnabled, MultipleDaySleepCost, ExperienceMultiplier, FriendshipMultiplier, DebrisMultiplier, QuickStart, Gifting, Mods, BundlePlando, \ - StardewValleyOptions, enabled_mods, disabled_mods, all_mods + StardewValleyOptions, enabled_mods, disabled_mods, all_mods, TrapDistribution, TrapItems, StardewValleyOptions diff --git a/worlds/stardew_valley/options/option_groups.py b/worlds/stardew_valley/options/option_groups.py index bcb9bee7..4ae1fc3c 100644 --- a/worlds/stardew_valley/options/option_groups.py +++ b/worlds/stardew_valley/options/option_groups.py @@ -52,7 +52,8 @@ else: options.DebrisMultiplier, options.NumberOfMovementBuffs, options.EnabledFillerBuffs, - options.TrapItems, + options.TrapDifficulty, + options.TrapDistribution, options.MultipleDaySleepEnabled, options.MultipleDaySleepCost, options.QuickStart, diff --git a/worlds/stardew_valley/options/options.py b/worlds/stardew_valley/options/options.py index 84026387..f81cdaac 100644 --- a/worlds/stardew_valley/options/options.py +++ b/worlds/stardew_valley/options/options.py @@ -3,7 +3,8 @@ import typing from dataclasses import dataclass from typing import Protocol, ClassVar -from Options import Range, NamedRange, Toggle, Choice, OptionSet, PerGameCommonOptions, DeathLink, OptionList, Visibility +from Options import Range, NamedRange, Toggle, Choice, OptionSet, PerGameCommonOptions, DeathLink, OptionList, Visibility, Removed, OptionCounter +from ..items import items_by_group, Group from ..mods.mod_data import ModNames from ..strings.ap_names.ap_option_names import BuffOptionName, WalnutsanityOptionName from ..strings.bundle_names import all_cc_bundle_names @@ -658,13 +659,29 @@ class ExcludeGingerIsland(Toggle): default = 0 -class TrapItems(Choice): - """When rolling filler items, including resource packs, the game can also roll trap items. - Trap items are negative items that cause problems or annoyances for the player - This setting is for choosing if traps will be in the item pool, and if so, how punishing they will be. +class TrapItems(Removed): + """Deprecated setting, replaced by TrapDifficulty """ internal_name = "trap_items" display_name = "Trap Items" + default = "" + visibility = Visibility.none + + def __init__(self, value: str): + if value: + raise Exception("Option trap_items was replaced by trap_difficulty, please update your options file") + super().__init__(value) + + +class TrapDifficulty(Choice): + """When rolling filler items, including resource packs, the game can also roll trap items. + Trap items are negative items that cause problems or annoyances for the player. + This setting is for choosing how punishing traps will be. + Lower difficulties will be on the funny annoyance side, higher difficulty will be on the extreme problems side. + Only play Nightmare at your own risk. + """ + internal_name = "trap_difficulty" + display_name = "Trap Difficulty" default = 2 option_no_traps = 0 option_easy = 1 @@ -674,6 +691,34 @@ class TrapItems(Choice): option_nightmare = 5 +trap_default_weight = 100 + + +class TrapDistribution(OptionCounter): + """ + Specify the weighted chance of rolling individual traps when rolling random filler items. + The average filler item should be considered to be "100", as in 100%. + So a trap on "200" will be twice as likely to roll as any filler item. A trap on "10" will be 10% as likely. + You can use weight "0" to disable this trap entirely. The maximum weight is 1000, for x10 chance + """ + internal_name = "trap_distribution" + display_name = "Trap Distribution" + default_weight = trap_default_weight + visibility = Visibility.all ^ Visibility.simple_ui + min = 0 + max = 1000 + valid_keys = frozenset({ + trap_data.name + for trap_data in items_by_group[Group.TRAP] + if Group.DEPRECATED not in trap_data.groups + }) + default = { + trap_data.name: trap_default_weight + for trap_data in items_by_group[Group.TRAP] + if Group.DEPRECATED not in trap_data.groups + } + + class MultipleDaySleepEnabled(Toggle): """Enable the ability to sleep automatically for multiple days straight?""" internal_name = "multiple_day_sleep_enabled" @@ -851,10 +896,14 @@ class StardewValleyOptions(PerGameCommonOptions): debris_multiplier: DebrisMultiplier movement_buff_number: NumberOfMovementBuffs enabled_filler_buffs: EnabledFillerBuffs - trap_items: TrapItems + trap_difficulty: TrapDifficulty + trap_distribution: TrapDistribution multiple_day_sleep_enabled: MultipleDaySleepEnabled multiple_day_sleep_cost: MultipleDaySleepCost gifting: Gifting mods: Mods bundle_plando: BundlePlando death_link: DeathLink + + # removed: + trap_items: TrapItems \ No newline at end of file diff --git a/worlds/stardew_valley/options/presets.py b/worlds/stardew_valley/options/presets.py index 3dbb5ab3..a711fe08 100644 --- a/worlds/stardew_valley/options/presets.py +++ b/worlds/stardew_valley/options/presets.py @@ -38,7 +38,7 @@ all_random_settings = { options.Booksanity.internal_name: "random", options.NumberOfMovementBuffs.internal_name: "random", options.ExcludeGingerIsland.internal_name: "random", - options.TrapItems.internal_name: "random", + options.TrapDifficulty.internal_name: "random", options.MultipleDaySleepEnabled.internal_name: "random", options.MultipleDaySleepCost.internal_name: "random", options.ExperienceMultiplier.internal_name: "random", @@ -82,7 +82,7 @@ easy_settings = { options.NumberOfMovementBuffs.internal_name: 8, options.EnabledFillerBuffs.internal_name: options.EnabledFillerBuffs.preset_all, options.ExcludeGingerIsland.internal_name: options.ExcludeGingerIsland.option_true, - options.TrapItems.internal_name: options.TrapItems.option_easy, + options.TrapDifficulty.internal_name: options.TrapDifficulty.option_easy, options.MultipleDaySleepEnabled.internal_name: options.MultipleDaySleepEnabled.option_true, options.MultipleDaySleepCost.internal_name: "free", options.ExperienceMultiplier.internal_name: "triple", @@ -126,7 +126,7 @@ medium_settings = { options.NumberOfMovementBuffs.internal_name: 6, options.EnabledFillerBuffs.internal_name: options.EnabledFillerBuffs.preset_all, options.ExcludeGingerIsland.internal_name: options.ExcludeGingerIsland.option_true, - options.TrapItems.internal_name: options.TrapItems.option_medium, + options.TrapDifficulty.internal_name: options.TrapDifficulty.option_medium, options.MultipleDaySleepEnabled.internal_name: options.MultipleDaySleepEnabled.option_true, options.MultipleDaySleepCost.internal_name: "free", options.ExperienceMultiplier.internal_name: "double", @@ -170,7 +170,7 @@ hard_settings = { options.NumberOfMovementBuffs.internal_name: 4, options.EnabledFillerBuffs.internal_name: options.EnabledFillerBuffs.default, options.ExcludeGingerIsland.internal_name: options.ExcludeGingerIsland.option_false, - options.TrapItems.internal_name: options.TrapItems.option_hard, + options.TrapDifficulty.internal_name: options.TrapDifficulty.option_hard, options.MultipleDaySleepEnabled.internal_name: options.MultipleDaySleepEnabled.option_true, options.MultipleDaySleepCost.internal_name: "cheap", options.ExperienceMultiplier.internal_name: "vanilla", @@ -214,7 +214,7 @@ nightmare_settings = { options.NumberOfMovementBuffs.internal_name: 2, options.EnabledFillerBuffs.internal_name: options.EnabledFillerBuffs.preset_none, options.ExcludeGingerIsland.internal_name: options.ExcludeGingerIsland.option_false, - options.TrapItems.internal_name: options.TrapItems.option_hell, + options.TrapDifficulty.internal_name: options.TrapDifficulty.option_hell, options.MultipleDaySleepEnabled.internal_name: options.MultipleDaySleepEnabled.option_true, options.MultipleDaySleepCost.internal_name: "expensive", options.ExperienceMultiplier.internal_name: "half", @@ -258,7 +258,7 @@ short_settings = { options.NumberOfMovementBuffs.internal_name: 10, options.EnabledFillerBuffs.internal_name: options.EnabledFillerBuffs.preset_all, options.ExcludeGingerIsland.internal_name: options.ExcludeGingerIsland.option_true, - options.TrapItems.internal_name: options.TrapItems.option_easy, + options.TrapDifficulty.internal_name: options.TrapDifficulty.option_easy, options.MultipleDaySleepEnabled.internal_name: options.MultipleDaySleepEnabled.option_true, options.MultipleDaySleepCost.internal_name: "free", options.ExperienceMultiplier.internal_name: "quadruple", @@ -302,7 +302,7 @@ minsanity_settings = { options.NumberOfMovementBuffs.internal_name: options.NumberOfMovementBuffs.default, options.EnabledFillerBuffs.internal_name: options.EnabledFillerBuffs.default, options.ExcludeGingerIsland.internal_name: options.ExcludeGingerIsland.option_true, - options.TrapItems.internal_name: options.TrapItems.default, + options.TrapDifficulty.internal_name: options.TrapDifficulty.default, options.MultipleDaySleepEnabled.internal_name: options.MultipleDaySleepEnabled.default, options.MultipleDaySleepCost.internal_name: options.MultipleDaySleepCost.default, options.ExperienceMultiplier.internal_name: options.ExperienceMultiplier.default, @@ -346,7 +346,7 @@ allsanity_settings = { options.NumberOfMovementBuffs.internal_name: 12, options.EnabledFillerBuffs.internal_name: options.EnabledFillerBuffs.preset_all, options.ExcludeGingerIsland.internal_name: options.ExcludeGingerIsland.option_false, - options.TrapItems.internal_name: options.TrapItems.default, + options.TrapDifficulty.internal_name: options.TrapDifficulty.default, options.MultipleDaySleepEnabled.internal_name: options.MultipleDaySleepEnabled.default, options.MultipleDaySleepCost.internal_name: options.MultipleDaySleepCost.default, options.ExperienceMultiplier.internal_name: options.ExperienceMultiplier.default, diff --git a/worlds/stardew_valley/test/TestGeneration.py b/worlds/stardew_valley/test/TestGeneration.py index 77092c78..6d0846f8 100644 --- a/worlds/stardew_valley/test/TestGeneration.py +++ b/worlds/stardew_valley/test/TestGeneration.py @@ -2,8 +2,8 @@ from typing import List from BaseClasses import ItemClassification, Item from . import SVTestBase -from .. import items, location_table, options -from ..items import Group, ItemData +from .. import location_table, options, items +from ..items import Group, ItemData, item_data from ..locations import LocationTags from ..options import Friendsanity, SpecialOrderLocations, Shipsanity, Chefsanity, SeasonRandomization, Craftsanity, ExcludeGingerIsland, SkillProgression, \ Booksanity, Walnutsanity @@ -15,10 +15,10 @@ def get_all_permanent_progression_items() -> List[ItemData]: """ return [ item - for item in items.all_items + for item in item_data.all_items if ItemClassification.progression in item.classification if item.mod_name is None - if item.name not in {event.name for event in items.events} + if item.name not in {event.name for event in item_data.events} if item.name not in {deprecated.name for deprecated in items.items_by_group[Group.DEPRECATED]} if item.name not in {season.name for season in items.items_by_group[Group.SEASON]} if item.name not in {weapon.name for weapon in items.items_by_group[Group.WEAPON]} @@ -54,19 +54,19 @@ class TestBaseItemGeneration(SVTestBase): def test_does_not_create_deprecated_items(self): all_created_items = set(self.get_all_created_items()) - for deprecated_item in items.items_by_group[items.Group.DEPRECATED]: + for deprecated_item in item_data.items_by_group[item_data.Group.DEPRECATED]: with self.subTest(f"{deprecated_item.name}"): self.assertNotIn(deprecated_item.name, all_created_items) def test_does_not_create_more_than_one_maximum_one_items(self): all_created_items = self.get_all_created_items() - for maximum_one_item in items.items_by_group[items.Group.MAXIMUM_ONE]: + for maximum_one_item in item_data.items_by_group[item_data.Group.MAXIMUM_ONE]: with self.subTest(f"{maximum_one_item.name}"): self.assertLessEqual(all_created_items.count(maximum_one_item.name), 1) def test_does_not_create_or_create_two_of_exactly_two_items(self): all_created_items = self.get_all_created_items() - for exactly_two_item in items.items_by_group[items.Group.AT_LEAST_TWO]: + for exactly_two_item in item_data.items_by_group[item_data.Group.AT_LEAST_TWO]: with self.subTest(f"{exactly_two_item.name}"): count = all_created_items.count(exactly_two_item.name) self.assertTrue(count == 0 or count == 2) @@ -102,19 +102,19 @@ class TestNoGingerIslandItemGeneration(SVTestBase): def test_does_not_create_deprecated_items(self): all_created_items = self.get_all_created_items() - for deprecated_item in items.items_by_group[items.Group.DEPRECATED]: + for deprecated_item in item_data.items_by_group[item_data.Group.DEPRECATED]: with self.subTest(f"Deprecated item: {deprecated_item.name}"): self.assertNotIn(deprecated_item.name, all_created_items) def test_does_not_create_more_than_one_maximum_one_items(self): all_created_items = self.get_all_created_items() - for maximum_one_item in items.items_by_group[items.Group.MAXIMUM_ONE]: + for maximum_one_item in item_data.items_by_group[item_data.Group.MAXIMUM_ONE]: with self.subTest(f"{maximum_one_item.name}"): self.assertLessEqual(all_created_items.count(maximum_one_item.name), 1) def test_does_not_create_exactly_two_items(self): all_created_items = self.get_all_created_items() - for exactly_two_item in items.items_by_group[items.Group.AT_LEAST_TWO]: + for exactly_two_item in item_data.items_by_group[item_data.Group.AT_LEAST_TWO]: with self.subTest(f"{exactly_two_item.name}"): count = all_created_items.count(exactly_two_item.name) self.assertTrue(count == 0 or count == 2) diff --git a/worlds/stardew_valley/test/TestItemLink.py b/worlds/stardew_valley/test/TestItemLink.py index 3a0d9765..f1c83461 100644 --- a/worlds/stardew_valley/test/TestItemLink.py +++ b/worlds/stardew_valley/test/TestItemLink.py @@ -6,7 +6,7 @@ max_iterations = 2000 class TestItemLinksEverythingIncluded(SVTestBase): options = {options.ExcludeGingerIsland.internal_name: options.ExcludeGingerIsland.option_false, - options.TrapItems.internal_name: options.TrapItems.option_medium} + options.TrapDifficulty.internal_name: options.TrapDifficulty.option_medium} def test_filler_of_all_types_generated(self): max_number_filler = 114 @@ -33,7 +33,7 @@ class TestItemLinksEverythingIncluded(SVTestBase): class TestItemLinksNoIsland(SVTestBase): options = {options.ExcludeGingerIsland.internal_name: options.ExcludeGingerIsland.option_true, - options.TrapItems.internal_name: options.TrapItems.option_medium} + options.TrapDifficulty.internal_name: options.TrapDifficulty.option_medium} def test_filler_has_no_island_but_has_traps(self): max_number_filler = 109 @@ -57,7 +57,7 @@ class TestItemLinksNoIsland(SVTestBase): class TestItemLinksNoTraps(SVTestBase): options = {options.ExcludeGingerIsland.internal_name: options.ExcludeGingerIsland.option_false, - options.TrapItems.internal_name: options.TrapItems.option_no_traps} + options.TrapDifficulty.internal_name: options.TrapDifficulty.option_no_traps} def test_filler_has_no_traps_but_has_island(self): max_number_filler = 99 @@ -81,7 +81,7 @@ class TestItemLinksNoTraps(SVTestBase): class TestItemLinksNoTrapsAndIsland(SVTestBase): options = {options.ExcludeGingerIsland.internal_name: options.ExcludeGingerIsland.option_true, - options.TrapItems.internal_name: options.TrapItems.option_no_traps} + options.TrapDifficulty.internal_name: options.TrapDifficulty.option_no_traps} def test_filler_generated_without_island_or_traps(self): max_number_filler = 94 diff --git a/worlds/stardew_valley/test/TestOptions.py b/worlds/stardew_valley/test/TestOptions.py index 4894ea55..11b0a014 100644 --- a/worlds/stardew_valley/test/TestOptions.py +++ b/worlds/stardew_valley/test/TestOptions.py @@ -9,7 +9,7 @@ from .options.option_names import all_option_choices from .options.presets import allsanity_no_mods_6_x_x, allsanity_mods_6_x_x from .. import items_by_group, Group from ..locations import locations_by_tag, LocationTags, location_table -from ..options import ExcludeGingerIsland, ToolProgression, Goal, SeasonRandomization, TrapItems, SpecialOrderLocations, ArcadeMachineLocations +from ..options import ExcludeGingerIsland, ToolProgression, Goal, SeasonRandomization, TrapDifficulty, SpecialOrderLocations, ArcadeMachineLocations from ..strings.goal_names import Goal as GoalName from ..strings.season_names import Season from ..strings.special_order_names import SpecialOrder @@ -126,7 +126,7 @@ class TestGenerateAllOptionsWithExcludeGingerIsland(WorldAssertMixin, SVTestCase class TestTraps(SVTestCase): def test_given_no_traps_when_generate_then_no_trap_in_pool(self): world_options = allsanity_no_mods_6_x_x().copy() - world_options[TrapItems.internal_name] = TrapItems.option_no_traps + world_options[TrapDifficulty.internal_name] = TrapDifficulty.option_no_traps with solo_multiworld(world_options) as (multi_world, _): trap_items = [item_data.name for item_data in items_by_group[Group.TRAP]] multiworld_items = [item.name for item in multi_world.get_items()] @@ -136,12 +136,12 @@ class TestTraps(SVTestCase): self.assertNotIn(item, multiworld_items) def test_given_traps_when_generate_then_all_traps_in_pool(self): - trap_option = TrapItems + trap_option = TrapDifficulty for value in trap_option.options: if value == "no_traps": continue world_options = allsanity_mods_6_x_x() - world_options.update({TrapItems.internal_name: trap_option.options[value]}) + world_options.update({TrapDifficulty.internal_name: trap_option.options[value]}) with solo_multiworld(world_options) as (multi_world, _): trap_items = [item_data.name for item_data in items_by_group[Group.TRAP] if Group.DEPRECATED not in item_data.groups and item_data.mod_name is None] diff --git a/worlds/stardew_valley/test/TestTraps.py b/worlds/stardew_valley/test/TestTraps.py new file mode 100644 index 00000000..9df07a6d --- /dev/null +++ b/worlds/stardew_valley/test/TestTraps.py @@ -0,0 +1,122 @@ +import unittest + +from . import SVTestBase +from .assertion import WorldAssertMixin +from .. import options, items_by_group, Group +from ..options import TrapDistribution + +default_distribution = {trap.name: TrapDistribution.default_weight for trap in items_by_group[Group.TRAP] if Group.DEPRECATED not in trap.groups} +threshold_difference = 2 +threshold_ballpark = 3 + + +class TestTrapDifficultyCanRemoveAllTraps(WorldAssertMixin, SVTestBase): + options = { + options.QuestLocations.internal_name: 56, + options.Fishsanity.internal_name: options.Fishsanity.option_all, + options.Museumsanity.internal_name: options.Museumsanity.option_all, + options.SpecialOrderLocations.internal_name: options.SpecialOrderLocations.option_board_qi, + options.Shipsanity.internal_name: options.Shipsanity.option_everything, + options.Cooksanity.internal_name: options.Cooksanity.option_all, + options.Craftsanity.internal_name: options.Craftsanity.option_all, + options.Mods.internal_name: frozenset(options.Mods.valid_keys), + options.TrapDifficulty.internal_name: options.TrapDifficulty.option_no_traps, + } + + def test_no_traps_in_item_pool(self): + items = self.multiworld.get_items() + item_names = set(item.name for item in items) + for trap in items_by_group[Group.TRAP]: + if Group.DEPRECATED in trap.groups: + continue + self.assertNotIn(trap.name, item_names) + + +class TestDefaultDistributionHasAllTraps(WorldAssertMixin, SVTestBase): + options = { + options.QuestLocations.internal_name: 56, + options.Fishsanity.internal_name: options.Fishsanity.option_all, + options.Museumsanity.internal_name: options.Museumsanity.option_all, + options.SpecialOrderLocations.internal_name: options.SpecialOrderLocations.option_board_qi, + options.Shipsanity.internal_name: options.Shipsanity.option_everything, + options.Cooksanity.internal_name: options.Cooksanity.option_all, + options.Craftsanity.internal_name: options.Craftsanity.option_all, + options.Mods.internal_name: frozenset(options.Mods.valid_keys), + options.TrapDifficulty.internal_name: options.TrapDifficulty.option_medium, + } + + def test_all_traps_in_item_pool(self): + items = self.multiworld.get_items() + item_names = set(item.name for item in items) + for trap in items_by_group[Group.TRAP]: + if Group.DEPRECATED in trap.groups: + continue + self.assertIn(trap.name, item_names) + + +class TestDistributionIsRespectedAllTraps(WorldAssertMixin, SVTestBase): + options = { + options.QuestLocations.internal_name: 56, + options.Fishsanity.internal_name: options.Fishsanity.option_all, + options.Museumsanity.internal_name: options.Museumsanity.option_all, + options.SpecialOrderLocations.internal_name: options.SpecialOrderLocations.option_board_qi, + options.Shipsanity.internal_name: options.Shipsanity.option_everything, + options.Cooksanity.internal_name: options.Cooksanity.option_all, + options.Craftsanity.internal_name: options.Craftsanity.option_all, + options.Mods.internal_name: frozenset(options.Mods.valid_keys), + options.TrapDifficulty.internal_name: options.TrapDifficulty.option_medium, + options.TrapDistribution.internal_name: default_distribution | {"Nudge Trap": 100, "Bark Trap": 1, "Meow Trap": 1000, "Shuffle Trap": 0} + } + + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + if cls.skip_long_tests: + raise unittest.SkipTest("Unstable tests disabled to not annoy anyone else when it rarely fails") + + def test_about_as_many_nudges_as_other_filler(self): + items = self.multiworld.get_items() + item_names = [item.name for item in items] + num_nudge = len([item for item in item_names if item == "Nudge Trap"]) + other_fillers = ["Resource Pack: 4 Frozen Geode", "Resource Pack: 50 Wood", "Resource Pack: 5 Warp Totem: Farm", + "Resource Pack: 500 Money", "Resource Pack: 75 Copper Ore", "Resource Pack: 30 Speed-Gro"] + at_least_one_in_ballpark = False + for filler_item in other_fillers: + num_filler = len([item for item in item_names if item == filler_item]) + diff_num = abs(num_filler - num_nudge) + is_in_ballpark = diff_num <= threshold_ballpark + at_least_one_in_ballpark = at_least_one_in_ballpark or is_in_ballpark + self.assertTrue(at_least_one_in_ballpark) + + def test_fewer_barks_than_nudges_in_item_pool(self): + items = self.multiworld.get_items() + item_names = [item.name for item in items] + num_nudge = len([item for item in item_names if item == "Nudge Trap"]) + num_bark = len([item for item in item_names if item == "Bark Trap"]) + self.assertLess(num_bark, num_nudge - threshold_difference) + + def test_more_meows_than_nudges_in_item_pool(self): + items = self.multiworld.get_items() + item_names = [item.name for item in items] + num_nudge = len([item for item in item_names if item == "Nudge Trap"]) + num_meow = len([item for item in item_names if item == "Meow Trap"]) + self.assertGreater(num_meow, num_nudge + threshold_difference) + + def test_no_shuffles_in_item_pool(self): + items = self.multiworld.get_items() + item_names = [item.name for item in items] + num_shuffle = len([item for item in item_names if item == "Shuffle Trap"]) + self.assertEqual(0, num_shuffle) + + def test_omitted_item_same_as_nudge_in_item_pool(self): + items = self.multiworld.get_items() + item_names = [item.name for item in items] + num_time_flies = len([item for item in item_names if item == "Time Flies Trap"]) + num_debris = len([item for item in item_names if item == "Debris Trap"]) + num_bark = len([item for item in item_names if item == "Bark Trap"]) + num_meow = len([item for item in item_names if item == "Meow Trap"]) + self.assertLess(num_bark, num_time_flies - threshold_difference) + self.assertLess(num_bark, num_debris - threshold_difference) + self.assertGreater(num_meow, num_time_flies + threshold_difference) + self.assertGreater(num_meow, num_debris + threshold_difference) + diff --git a/worlds/stardew_valley/test/__init__.py b/worlds/stardew_valley/test/__init__.py index 702f5902..6a8011a3 100644 --- a/worlds/stardew_valley/test/__init__.py +++ b/worlds/stardew_valley/test/__init__.py @@ -14,7 +14,7 @@ from .assertion import RuleAssertMixin from .options.utils import fill_namespace_with_default, parse_class_option_keys, fill_dataclass_with_default from .. import StardewValleyWorld, StardewItem, StardewRule from ..logic.time_logic import MONTH_COEFFICIENT -from ..options import StardewValleyOption +from ..options import StardewValleyOption, options logger = logging.getLogger(__name__) @@ -221,9 +221,9 @@ def setup_solo_multiworld(test_options: Optional[Dict[Union[str, StardewValleyOp # 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 + should_cache = should_cache_world(test_options) if should_cache: - frozen_options = frozenset(test_options.items()).union({("seed", seed)}) + frozen_options = make_hashable(test_options, 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)}]") @@ -252,6 +252,27 @@ def setup_solo_multiworld(test_options: Optional[Dict[Union[str, StardewValleyOp return multiworld +def should_cache_world(test_options): + if "start_inventory" in test_options: + return False + + trap_distribution_key = "trap_distribution" + if trap_distribution_key not in test_options: + return True + + trap_distribution = test_options[trap_distribution_key] + for key in trap_distribution: + if trap_distribution[key] != options.TrapDistribution.default_weight: + return False + + return True + + + +def make_hashable(test_options, seed): + return frozenset(test_options.items()).union({("seed", seed)}) + + def search_world_cache(cache: Dict[frozenset, MultiWorld], frozen_options: frozenset) -> Optional[MultiWorld]: try: return cache[frozen_options] diff --git a/worlds/stardew_valley/test/options/TestPresets.py b/worlds/stardew_valley/test/options/TestPresets.py index 9384acd7..5d9e8953 100644 --- a/worlds/stardew_valley/test/options/TestPresets.py +++ b/worlds/stardew_valley/test/options/TestPresets.py @@ -1,16 +1,16 @@ -from Options import PerGameCommonOptions, OptionSet +from Options import PerGameCommonOptions, OptionSet, OptionDict from .. import SVTestCase -from ...options import StardewValleyOptions +from ...options import StardewValleyOptions, TrapItems from ...options.presets import sv_options_presets class TestPresets(SVTestCase): def test_all_presets_explicitly_set_all_options(self): all_option_names = {option_key for option_key in StardewValleyOptions.type_hints} - omitted_option_names = {option_key for option_key in PerGameCommonOptions.type_hints} + omitted_option_names = {option_key for option_key in PerGameCommonOptions.type_hints} | {TrapItems.internal_name} mandatory_option_names = {option_key for option_key in all_option_names if option_key not in omitted_option_names and - not issubclass(StardewValleyOptions.type_hints[option_key], OptionSet)} + not issubclass(StardewValleyOptions.type_hints[option_key], OptionSet | OptionDict)} for preset_name in sv_options_presets: with self.subTest(f"{preset_name}"): diff --git a/worlds/stardew_valley/test/options/presets.py b/worlds/stardew_valley/test/options/presets.py index 57f8b0be..86b21c69 100644 --- a/worlds/stardew_valley/test/options/presets.py +++ b/worlds/stardew_valley/test/options/presets.py @@ -70,7 +70,7 @@ def allsanity_no_mods_6_x_x(): 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.TrapDifficulty.internal_name: options.TrapDifficulty.option_nightmare, options.Walnutsanity.internal_name: options.Walnutsanity.preset_all } @@ -119,7 +119,7 @@ def get_minsanity_options(): 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.TrapDifficulty.internal_name: options.TrapDifficulty.option_no_traps, options.Walnutsanity.internal_name: options.Walnutsanity.preset_none } @@ -156,7 +156,7 @@ def minimal_locations_maximal_items(): 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.TrapDifficulty.internal_name: options.TrapDifficulty.option_nightmare, options.Walnutsanity.internal_name: options.Walnutsanity.preset_none } return min_max_options