From 139856a5731752bfdac22a5445422fb50d194b8d Mon Sep 17 00:00:00 2001 From: agilbert1412 Date: Sun, 29 Mar 2026 17:21:29 -0400 Subject: [PATCH] Stardew Valley: Fixed an issue where some specific option combinations could create more items than locations (#6012) * - Improved the dynamic locations count algorithm to take into account the nature of various heavy settings in both directions * - Fixes from Code Review * - We're only testing for sunday locations, might as well only take sunday locations in the list to test * - One more slight optimization * - Added consideration for bundles per room in filler locations counting * - Registered some more IDs to handle items up to 10 --- worlds/stardew_valley/data/locations.csv | 14 +++++ worlds/stardew_valley/locations.py | 46 +++++++++++--- .../test/TestNumberLocations.py | 15 +++-- .../test/long/TestNumberLocationsLong.py | 62 +++++++++++++++++++ worlds/stardew_valley/test/options/presets.py | 45 ++++++++++++++ 5 files changed, 170 insertions(+), 12 deletions(-) create mode 100644 worlds/stardew_valley/test/long/TestNumberLocationsLong.py diff --git a/worlds/stardew_valley/data/locations.csv b/worlds/stardew_valley/data/locations.csv index eea7c61150..8c4a521d77 100644 --- a/worlds/stardew_valley/data/locations.csv +++ b/worlds/stardew_valley/data/locations.csv @@ -438,6 +438,8 @@ id,region,name,tags,content_packs 906,Traveling Cart Sunday,Traveling Merchant Sunday Item 6,"TRAVELING_MERCHANT", 907,Traveling Cart Sunday,Traveling Merchant Sunday Item 7,"TRAVELING_MERCHANT", 908,Traveling Cart Sunday,Traveling Merchant Sunday Item 8,"TRAVELING_MERCHANT", +909,Traveling Cart Sunday,Traveling Merchant Sunday Item 9,"TRAVELING_MERCHANT", +910,Traveling Cart Sunday,Traveling Merchant Sunday Item 10,"TRAVELING_MERCHANT", 911,Traveling Cart Monday,Traveling Merchant Monday Item 1,"MANDATORY,TRAVELING_MERCHANT", 912,Traveling Cart Monday,Traveling Merchant Monday Item 2,"TRAVELING_MERCHANT", 913,Traveling Cart Monday,Traveling Merchant Monday Item 3,"TRAVELING_MERCHANT", @@ -446,6 +448,8 @@ id,region,name,tags,content_packs 916,Traveling Cart Monday,Traveling Merchant Monday Item 6,"TRAVELING_MERCHANT", 917,Traveling Cart Monday,Traveling Merchant Monday Item 7,"TRAVELING_MERCHANT", 918,Traveling Cart Monday,Traveling Merchant Monday Item 8,"TRAVELING_MERCHANT", +919,Traveling Cart Monday,Traveling Merchant Monday Item 9,"TRAVELING_MERCHANT", +920,Traveling Cart Monday,Traveling Merchant Monday Item 10,"TRAVELING_MERCHANT", 921,Traveling Cart Tuesday,Traveling Merchant Tuesday Item 1,"MANDATORY,TRAVELING_MERCHANT", 922,Traveling Cart Tuesday,Traveling Merchant Tuesday Item 2,"TRAVELING_MERCHANT", 923,Traveling Cart Tuesday,Traveling Merchant Tuesday Item 3,"TRAVELING_MERCHANT", @@ -454,6 +458,8 @@ id,region,name,tags,content_packs 926,Traveling Cart Tuesday,Traveling Merchant Tuesday Item 6,"TRAVELING_MERCHANT", 927,Traveling Cart Tuesday,Traveling Merchant Tuesday Item 7,"TRAVELING_MERCHANT", 928,Traveling Cart Tuesday,Traveling Merchant Tuesday Item 8,"TRAVELING_MERCHANT", +929,Traveling Cart Tuesday,Traveling Merchant Tuesday Item 9,"TRAVELING_MERCHANT", +930,Traveling Cart Tuesday,Traveling Merchant Tuesday Item 10,"TRAVELING_MERCHANT", 931,Traveling Cart Wednesday,Traveling Merchant Wednesday Item 1,"MANDATORY,TRAVELING_MERCHANT", 932,Traveling Cart Wednesday,Traveling Merchant Wednesday Item 2,"TRAVELING_MERCHANT", 933,Traveling Cart Wednesday,Traveling Merchant Wednesday Item 3,"TRAVELING_MERCHANT", @@ -462,6 +468,8 @@ id,region,name,tags,content_packs 936,Traveling Cart Wednesday,Traveling Merchant Wednesday Item 6,"TRAVELING_MERCHANT", 937,Traveling Cart Wednesday,Traveling Merchant Wednesday Item 7,"TRAVELING_MERCHANT", 938,Traveling Cart Wednesday,Traveling Merchant Wednesday Item 8,"TRAVELING_MERCHANT", +939,Traveling Cart Wednesday,Traveling Merchant Wednesday Item 9,"TRAVELING_MERCHANT", +940,Traveling Cart Wednesday,Traveling Merchant Wednesday Item 10,"TRAVELING_MERCHANT", 941,Traveling Cart Thursday,Traveling Merchant Thursday Item 1,"MANDATORY,TRAVELING_MERCHANT", 942,Traveling Cart Thursday,Traveling Merchant Thursday Item 2,"TRAVELING_MERCHANT", 943,Traveling Cart Thursday,Traveling Merchant Thursday Item 3,"TRAVELING_MERCHANT", @@ -470,6 +478,8 @@ id,region,name,tags,content_packs 946,Traveling Cart Thursday,Traveling Merchant Thursday Item 6,"TRAVELING_MERCHANT", 947,Traveling Cart Thursday,Traveling Merchant Thursday Item 7,"TRAVELING_MERCHANT", 948,Traveling Cart Thursday,Traveling Merchant Thursday Item 8,"TRAVELING_MERCHANT", +949,Traveling Cart Thursday,Traveling Merchant Thursday Item 9,"TRAVELING_MERCHANT", +950,Traveling Cart Thursday,Traveling Merchant Thursday Item 10,"TRAVELING_MERCHANT", 951,Traveling Cart Friday,Traveling Merchant Friday Item 1,"MANDATORY,TRAVELING_MERCHANT", 952,Traveling Cart Friday,Traveling Merchant Friday Item 2,"TRAVELING_MERCHANT", 953,Traveling Cart Friday,Traveling Merchant Friday Item 3,"TRAVELING_MERCHANT", @@ -478,6 +488,8 @@ id,region,name,tags,content_packs 956,Traveling Cart Friday,Traveling Merchant Friday Item 6,"TRAVELING_MERCHANT", 957,Traveling Cart Friday,Traveling Merchant Friday Item 7,"TRAVELING_MERCHANT", 958,Traveling Cart Friday,Traveling Merchant Friday Item 8,"TRAVELING_MERCHANT", +959,Traveling Cart Friday,Traveling Merchant Friday Item 9,"TRAVELING_MERCHANT", +960,Traveling Cart Friday,Traveling Merchant Friday Item 10,"TRAVELING_MERCHANT", 961,Traveling Cart Saturday,Traveling Merchant Saturday Item 1,"MANDATORY,TRAVELING_MERCHANT", 962,Traveling Cart Saturday,Traveling Merchant Saturday Item 2,"TRAVELING_MERCHANT", 963,Traveling Cart Saturday,Traveling Merchant Saturday Item 3,"TRAVELING_MERCHANT", @@ -486,6 +498,8 @@ id,region,name,tags,content_packs 966,Traveling Cart Saturday,Traveling Merchant Saturday Item 6,"TRAVELING_MERCHANT", 967,Traveling Cart Saturday,Traveling Merchant Saturday Item 7,"TRAVELING_MERCHANT", 968,Traveling Cart Saturday,Traveling Merchant Saturday Item 8,"TRAVELING_MERCHANT", +969,Traveling Cart Saturday,Traveling Merchant Saturday Item 9,"TRAVELING_MERCHANT", +970,Traveling Cart Saturday,Traveling Merchant Saturday Item 10,"TRAVELING_MERCHANT", 1001,Fishing,Fishsanity: Carp,FISHSANITY, 1002,Fishing,Fishsanity: Herring,FISHSANITY, 1003,Fishing,Fishsanity: Smallmouth Bass,FISHSANITY, diff --git a/worlds/stardew_valley/locations.py b/worlds/stardew_valley/locations.py index 613698ac1b..a817022e3d 100644 --- a/worlds/stardew_valley/locations.py +++ b/worlds/stardew_valley/locations.py @@ -1,6 +1,7 @@ import csv import enum import logging +import math from dataclasses import dataclass from random import Random from typing import Optional, Dict, Protocol, List, Iterable @@ -16,7 +17,7 @@ from .mods.mod_data import ModNames from .options import ArcadeMachineLocations, SpecialOrderLocations, Museumsanity, \ FestivalLocations, ElevatorProgression, BackpackProgression, FarmType from .options import StardewValleyOptions, Craftsanity, Chefsanity, Cooksanity, Shipsanity, Monstersanity -from .options.options import BackpackSize, Moviesanity, Eatsanity, IncludeEndgameLocations, Friendsanity +from .options.options import BackpackSize, Moviesanity, Eatsanity, IncludeEndgameLocations, Friendsanity, Fishsanity, SkillProgression, Cropsanity from .strings.ap_names.ap_option_names import WalnutsanityOptionName, SecretsanityOptionName, EatsanityOptionName, ChefsanityOptionName, StartWithoutOptionName from .strings.backpack_tiers import Backpack from .strings.goal_names import Goal @@ -665,19 +666,48 @@ def extend_endgame_locations(randomized_locations: List[LocationData], options: def extend_filler_locations(randomized_locations: List[LocationData], options: StardewValleyOptions, content: StardewContent): days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] - i = 1 - while len(randomized_locations) < 90: - location_name = f"Traveling Merchant Sunday Item {i}" - while any(location.name == location_name for location in randomized_locations): - i += 1 - location_name = f"Traveling Merchant Sunday Item {i}" + number_locations_to_add_per_day = 0 + min_number_locations = 90 # Under 90 locations we can run out of rooms for the mandatory core items + if len(randomized_locations) < min_number_locations: + number_locations_to_add = min_number_locations - len(randomized_locations) + number_locations_to_add_per_day += math.ceil(number_locations_to_add / 7) + + # These settings generate a lot of empty locations, so they can absorb a lot of items + filler_heavy_settings = [options.fishsanity != Fishsanity.option_none, + options.shipsanity != Shipsanity.option_none, + options.cooksanity != Cooksanity.option_none, + options.craftsanity != Craftsanity.option_none, + len(options.eatsanity.value) > 0, + options.museumsanity == Museumsanity.option_all, + options.quest_locations.value >= 0, + options.bundle_per_room >= 2] + # These settings generate orphan items and can cause too many items, if enabled without a complementary of the filler heavy settings + orphan_settings = [len(options.chefsanity.value) > 0, + options.friendsanity != Friendsanity.option_none, + options.skill_progression == SkillProgression.option_progressive_with_masteries, + options.cropsanity != Cropsanity.option_disabled, + len(options.start_without.value) > 0, + options.bundle_per_room <= -1, + options.bundle_per_room <= -2] + + enabled_filler_heavy_settings = len([val for val in filler_heavy_settings if val]) + enabled_orphan_settings = len([val for val in orphan_settings if val]) + if enabled_orphan_settings > enabled_filler_heavy_settings: + number_locations_to_add_per_day += enabled_orphan_settings - enabled_filler_heavy_settings + + if number_locations_to_add_per_day <= 0: + return + + existing_traveling_merchant_locations = [location.name for location in randomized_locations if location.name.startswith("Traveling Merchant Sunday Item ")] + start_num_to_add = len(existing_traveling_merchant_locations) + 1 + + for i in range(start_num_to_add, start_num_to_add+number_locations_to_add_per_day): logger.debug(f"Player too few locations, adding Traveling Merchant Items #{i}") for day in days: location_name = f"Traveling Merchant {day} Item {i}" randomized_locations.append(location_table[location_name]) - def create_locations(location_collector: StardewLocationCollector, bundle_rooms: List[BundleRoom], trash_bear_requests: Dict[str, List[str]], diff --git a/worlds/stardew_valley/test/TestNumberLocations.py b/worlds/stardew_valley/test/TestNumberLocations.py index b21488733b..50b158d282 100644 --- a/worlds/stardew_valley/test/TestNumberLocations.py +++ b/worlds/stardew_valley/test/TestNumberLocations.py @@ -7,6 +7,13 @@ from ..items import Group, item_table from ..items.item_data import FILLER_GROUPS +def get_real_item_count(multiworld): + number_items = len([item for item in multiworld.itempool + if all(filler_group not in item_table[item.name].groups for filler_group in FILLER_GROUPS) and Group.TRAP not in item_table[ + item.name].groups and (item.classification & ItemClassification.progression)]) + return number_items + + class TestLocationGeneration(SVTestBase): def test_all_location_created_are_in_location_table(self): @@ -20,8 +27,7 @@ class TestMinLocationAndMaxItem(SVTestBase): def test_minimal_location_maximal_items_still_valid(self): valid_locations = self.get_real_locations() number_locations = len(valid_locations) - number_items = len([item for item in self.multiworld.itempool - if all(filler_group not in item_table[item.name].groups for filler_group in FILLER_GROUPS) and Group.TRAP not in item_table[item.name].groups]) + number_items = get_real_item_count(self.multiworld) print(f"Stardew Valley - Minimum Locations: {number_locations}, Maximum Items: {number_items} [ISLAND EXCLUDED]") self.assertGreaterEqual(number_locations, number_items) @@ -32,8 +38,7 @@ class TestMinLocationAndMaxItemWithIsland(SVTestBase): def test_minimal_location_maximal_items_with_island_still_valid(self): valid_locations = self.get_real_locations() number_locations = len(valid_locations) - number_items = len([item for item in self.multiworld.itempool - if all(filler_group not in item_table[item.name].groups for filler_group in FILLER_GROUPS) and Group.TRAP not in item_table[item.name].groups and (item.classification & ItemClassification.progression)]) + number_items = get_real_item_count(self.multiworld) print(f"Stardew Valley - Minimum Locations: {number_locations}, Maximum Items: {number_items} [ISLAND INCLUDED]") self.assertGreaterEqual(number_locations, number_items) @@ -99,3 +104,5 @@ class TestAllSanityWithModsSettingsHasAllExpectedLocations(SVTestBase): f"\n\tPlease update test_allsanity_with_mods_has_at_least_locations" f"\n\t\tExpected: {expected_locations}" f"\n\t\tActual: {number_locations}") + + diff --git a/worlds/stardew_valley/test/long/TestNumberLocationsLong.py b/worlds/stardew_valley/test/long/TestNumberLocationsLong.py new file mode 100644 index 0000000000..9a46547aca --- /dev/null +++ b/worlds/stardew_valley/test/long/TestNumberLocationsLong.py @@ -0,0 +1,62 @@ +import unittest + +from BaseClasses import ItemClassification +from ..assertion import get_all_location_names +from ..bases import skip_long_tests, SVTestCase, solo_multiworld +from ..options.presets import setting_mins_and_maxes, allsanity_no_mods_7_x_x, get_minsanity_options, default_7_x_x +from ...items import Group, item_table +from ...items.item_data import FILLER_GROUPS + +if skip_long_tests(): + raise unittest.SkipTest("Long tests disabled") + + +def get_real_item_count(multiworld): + number_items = len([item for item in multiworld.itempool + if all(filler_group not in item_table[item.name].groups for filler_group in FILLER_GROUPS) and Group.TRAP not in item_table[ + item.name].groups and (item.classification & ItemClassification.progression)]) + return number_items + + +class TestCountsPerSetting(SVTestCase): + + def test_items_locations_counts_per_setting_with_ginger_island(self): + option_mins_and_maxes = setting_mins_and_maxes() + + for name in option_mins_and_maxes: + values = option_mins_and_maxes[name] + if not isinstance(values, list): + continue + with self.subTest(f"{name}"): + highest_variance_items = -1 + highest_variance_locations = -1 + for preset in [allsanity_no_mods_7_x_x, default_7_x_x, get_minsanity_options]: + lowest_items = 9999 + lowest_locations = 9999 + highest_items = -1 + highest_locations = -1 + for value in values: + world_options = preset() + world_options[name] = value + with solo_multiworld(world_options, world_caching=False) as (multiworld, _): + num_locations = len([loc for loc in get_all_location_names(multiworld) if not loc.startswith("Traveling Merchant")]) + num_items = get_real_item_count(multiworld) + if num_items > highest_items: + highest_items = num_items + if num_items < lowest_items: + lowest_items = num_items + if num_locations > highest_locations: + highest_locations = num_locations + if num_locations < lowest_locations: + lowest_locations = num_locations + + variance_items = highest_items - lowest_items + variance_locations = highest_locations - lowest_locations + if variance_locations > highest_variance_locations: + highest_variance_locations = variance_locations + if variance_items > highest_variance_items: + highest_variance_items = variance_items + if highest_variance_locations > highest_variance_items: + print(f"Options `{name}` can create up to {highest_variance_locations - highest_variance_items} filler ({highest_variance_locations} locations and up to {highest_variance_items} items)") + if highest_variance_locations < highest_variance_items: + print(f"Options `{name}` can create up to {highest_variance_items - highest_variance_locations} orphan ({highest_variance_locations} locations and up to {highest_variance_items} items)") \ No newline at end of file diff --git a/worlds/stardew_valley/test/options/presets.py b/worlds/stardew_valley/test/options/presets.py index 92aab191de..71ad32bb20 100644 --- a/worlds/stardew_valley/test/options/presets.py +++ b/worlds/stardew_valley/test/options/presets.py @@ -292,3 +292,48 @@ 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 + + +def setting_mins_and_maxes(): + low_orphan_options = { + options.ArcadeMachineLocations.internal_name: [options.ArcadeMachineLocations.option_disabled, options.ArcadeMachineLocations.option_full_shuffling], + options.BackpackProgression.internal_name: [options.BackpackProgression.option_vanilla, options.BackpackProgression.option_progressive], + options.BackpackSize.internal_name: [options.BackpackSize.option_1, options.BackpackSize.option_12], + options.Booksanity.internal_name: [options.Booksanity.option_none, options.Booksanity.option_power_skill, options.Booksanity.option_power, options.Booksanity.option_all], + options.BuildingProgression.internal_name: options.BuildingProgression.option_vanilla_cheap, + options.BundlePerRoom.internal_name: [options.BundlePerRoom.option_two_fewer, options.BundlePerRoom.option_four_extra], + options.BundlePrice.internal_name: options.BundlePrice.option_normal, + options.BundleRandomization.internal_name: options.BundleRandomization.option_remixed, + options.Chefsanity.internal_name: [options.Chefsanity.preset_none, options.Chefsanity.preset_all], + options.Cooksanity.internal_name: [options.Cooksanity.option_none, options.Cooksanity.option_all], + options.Craftsanity.internal_name: [options.Craftsanity.option_none, options.Craftsanity.option_all], + options.Cropsanity.internal_name: [options.Cropsanity.option_disabled, options.Cropsanity.option_enabled], + options.Eatsanity.internal_name: [options.Eatsanity.preset_none, options.Eatsanity.preset_all], + options.ElevatorProgression.internal_name: [options.ElevatorProgression.option_vanilla, options.ElevatorProgression.option_progressive], + options.EnabledFillerBuffs.internal_name: options.EnabledFillerBuffs.preset_all, + options.EntranceRandomization.internal_name: options.EntranceRandomization.option_disabled, + options.ExcludeGingerIsland.internal_name: [options.ExcludeGingerIsland.option_false, options.ExcludeGingerIsland.option_true], + options.FarmType.internal_name: [options.FarmType.option_standard, options.FarmType.option_meadowlands], + options.FestivalLocations.internal_name: [options.FestivalLocations.option_disabled, options.FestivalLocations.option_hard], + options.Fishsanity.internal_name: [options.Fishsanity.option_none, options.Fishsanity.option_all], + options.Friendsanity.internal_name: [options.Friendsanity.option_none, options.Friendsanity.option_all_with_marriage], + options.FriendsanityHeartSize.internal_name: [1, 8], + options.Goal.internal_name: options.Goal.option_allsanity, + options.IncludeEndgameLocations.internal_name: [options.IncludeEndgameLocations.option_false, options.IncludeEndgameLocations.option_true], + options.Mods.internal_name: frozenset(), + options.Monstersanity.internal_name: [options.Monstersanity.option_none, options.Monstersanity.option_one_per_monster], + options.Moviesanity.internal_name: [options.Moviesanity.option_none, options.Moviesanity.option_all_movies_and_all_loved_snacks], + options.Museumsanity.internal_name: [options.Museumsanity.option_none, options.Museumsanity.option_all], + options.NumberOfMovementBuffs.internal_name: [0, 12], + options.QuestLocations.internal_name: [-1, 56], + options.SeasonRandomization.internal_name: [options.SeasonRandomization.option_disabled, options.SeasonRandomization.option_randomized_not_winter], + options.Secretsanity.internal_name: [options.Secretsanity.preset_none, options.Secretsanity.preset_all], + options.Shipsanity.internal_name: [options.Shipsanity.option_none, options.Shipsanity.option_everything], + options.SkillProgression.internal_name: [options.SkillProgression.option_vanilla, options.SkillProgression.option_progressive_with_masteries], + options.SpecialOrderLocations.internal_name: [options.SpecialOrderLocations.option_vanilla, options.SpecialOrderLocations.option_board_qi], + options.StartWithout.internal_name: [options.StartWithout.preset_none, options.StartWithout.preset_all], + options.ToolProgression.internal_name: [options.ToolProgression.option_vanilla, options.ToolProgression.option_progressive], + options.TrapDifficulty.internal_name: options.TrapDifficulty.option_medium, + options.Walnutsanity.internal_name: [options.Walnutsanity.preset_none, options.Walnutsanity.preset_all], + } + return low_orphan_options