Stardew Valley: Properly support Universal Tracker (#3630)

* save the seed in slot data to reuse it in UT

* add logging when seed is missing

* add UT test and fix bundle test

* self review

* run UT test on allsanity+mod so it's more meaningfull
This commit is contained in:
Jérémie Bolduc 2024-07-26 05:33:14 -04:00 committed by GitHub
parent cc22161644
commit 9d36ad0df2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 105 additions and 26 deletions

View File

@ -1,4 +1,5 @@
import logging import logging
from random import Random
from typing import Dict, Any, Iterable, Optional, Union, List, TextIO from typing import Dict, Any, Iterable, Optional, Union, List, TextIO
from BaseClasses import Region, Entrance, Location, Item, Tutorial, ItemClassification, MultiWorld, CollectionState from BaseClasses import Region, Entrance, Location, Item, Tutorial, ItemClassification, MultiWorld, CollectionState
@ -27,15 +28,20 @@ from .strings.goal_names import Goal as GoalName
from .strings.metal_names import Ore from .strings.metal_names import Ore
from .strings.region_names import Region as RegionName, LogicRegion from .strings.region_names import Region as RegionName, LogicRegion
logger = logging.getLogger(__name__)
STARDEW_VALLEY = "Stardew Valley"
UNIVERSAL_TRACKER_SEED_PROPERTY = "ut_seed"
client_version = 0 client_version = 0
class StardewLocation(Location): class StardewLocation(Location):
game: str = "Stardew Valley" game: str = STARDEW_VALLEY
class StardewItem(Item): class StardewItem(Item):
game: str = "Stardew Valley" game: str = STARDEW_VALLEY
class StardewWebWorld(WebWorld): class StardewWebWorld(WebWorld):
@ -60,7 +66,7 @@ class StardewValleyWorld(World):
Stardew Valley is an open-ended country-life RPG. You can farm, fish, mine, fight, complete quests, Stardew Valley is an open-ended country-life RPG. You can farm, fish, mine, fight, complete quests,
befriend villagers, and uncover dark secrets. befriend villagers, and uncover dark secrets.
""" """
game = "Stardew Valley" game = STARDEW_VALLEY
topology_present = False topology_present = False
item_name_to_id = {name: data.code for name, data in item_table.items()} item_name_to_id = {name: data.code for name, data in item_table.items()}
@ -95,6 +101,17 @@ class StardewValleyWorld(World):
self.total_progression_items = 0 self.total_progression_items = 0
# self.all_progression_items = dict() # self.all_progression_items = dict()
# 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.")
return seed
def generate_early(self): def generate_early(self):
self.force_change_options_if_incompatible() self.force_change_options_if_incompatible()
self.content = create_content(self.options) self.content = create_content(self.options)
@ -108,12 +125,12 @@ class StardewValleyWorld(World):
self.options.exclude_ginger_island.value = ExcludeGingerIsland.option_false self.options.exclude_ginger_island.value = ExcludeGingerIsland.option_false
goal_name = self.options.goal.current_key goal_name = self.options.goal.current_key
player_name = self.multiworld.player_name[self.player] player_name = self.multiworld.player_name[self.player]
logging.warning( logger.warning(
f"Goal '{goal_name}' requires Ginger Island. Exclude Ginger Island setting forced to 'False' for player {self.player} ({player_name})") f"Goal '{goal_name}' requires Ginger Island. Exclude Ginger Island setting forced to 'False' for player {self.player} ({player_name})")
if exclude_ginger_island and self.options.walnutsanity != Walnutsanity.preset_none: if exclude_ginger_island and self.options.walnutsanity != Walnutsanity.preset_none:
self.options.walnutsanity.value = Walnutsanity.preset_none self.options.walnutsanity.value = Walnutsanity.preset_none
player_name = self.multiworld.player_name[self.player] player_name = self.multiworld.player_name[self.player]
logging.warning( logger.warning(
f"Walnutsanity requires Ginger Island. Ginger Island was excluded from {self.player} ({player_name})'s world, so walnutsanity was force disabled") f"Walnutsanity requires Ginger Island. Ginger Island was excluded from {self.player} ({player_name})'s world, so walnutsanity was force disabled")
def create_regions(self): def create_regions(self):
@ -413,6 +430,7 @@ class StardewValleyWorld(World):
included_option_names: List[str] = [option_name for option_name in self.options_dataclass.type_hints if option_name not in excluded_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 = self.options.as_dict(*included_option_names)
slot_data.update({ slot_data.update({
UNIVERSAL_TRACKER_SEED_PROPERTY: self.seed,
"seed": self.random.randrange(1000000000), # Seed should be max 9 digits "seed": self.random.randrange(1000000000), # Seed should be max 9 digits
"randomized_entrances": self.randomized_entrances, "randomized_entrances": self.randomized_entrances,
"modified_bundles": bundles, "modified_bundles": bundles,

View File

@ -137,7 +137,8 @@ vanilla_regions = [
[Entrance.island_west_to_islandfarmhouse, Entrance.island_west_to_gourmand_cave, Entrance.island_west_to_crystals_cave, [Entrance.island_west_to_islandfarmhouse, Entrance.island_west_to_gourmand_cave, Entrance.island_west_to_crystals_cave,
Entrance.island_west_to_shipwreck, Entrance.island_west_to_qi_walnut_room, Entrance.use_farm_obelisk, Entrance.parrot_express_jungle_to_docks, Entrance.island_west_to_shipwreck, Entrance.island_west_to_qi_walnut_room, Entrance.use_farm_obelisk, Entrance.parrot_express_jungle_to_docks,
Entrance.parrot_express_jungle_to_dig_site, Entrance.parrot_express_jungle_to_volcano, LogicEntrance.grow_spring_crops_on_island, Entrance.parrot_express_jungle_to_dig_site, Entrance.parrot_express_jungle_to_volcano, LogicEntrance.grow_spring_crops_on_island,
LogicEntrance.grow_summer_crops_on_island, LogicEntrance.grow_fall_crops_on_island, LogicEntrance.grow_winter_crops_on_island, LogicEntrance.grow_indoor_crops_on_island], LogicEntrance.grow_summer_crops_on_island, LogicEntrance.grow_fall_crops_on_island, LogicEntrance.grow_winter_crops_on_island,
LogicEntrance.grow_indoor_crops_on_island],
is_ginger_island=True), is_ginger_island=True),
RegionData(Region.island_east, [Entrance.island_east_to_leo_hut, Entrance.island_east_to_island_shrine], is_ginger_island=True), RegionData(Region.island_east, [Entrance.island_east_to_leo_hut, Entrance.island_east_to_island_shrine], is_ginger_island=True),
RegionData(Region.island_shrine, is_ginger_island=True), RegionData(Region.island_shrine, is_ginger_island=True),
@ -536,7 +537,7 @@ def create_final_regions(world_options) -> List[RegionData]:
def create_final_connections_and_regions(world_options) -> Tuple[Dict[str, ConnectionData], Dict[str, RegionData]]: def create_final_connections_and_regions(world_options) -> Tuple[Dict[str, ConnectionData], Dict[str, RegionData]]:
regions_data: Dict[str, RegionData] = {region.name: region for region in create_final_regions(world_options)} regions_data: Dict[str, RegionData] = {region.name: region for region in create_final_regions(world_options)}
connections = {connection.name: connection for connection in vanilla_connections} connections = {connection.name: connection for connection in vanilla_connections}
connections = modify_connections_for_mods(connections, world_options.mods) connections = modify_connections_for_mods(connections, sorted(world_options.mods.value))
include_island = world_options.exclude_ginger_island == ExcludeGingerIsland.option_false include_island = world_options.exclude_ginger_island == ExcludeGingerIsland.option_false
return remove_ginger_island_regions_and_connections(regions_data, connections, include_island) return remove_ginger_island_regions_and_connections(regions_data, connections, include_island)
@ -563,10 +564,8 @@ def remove_ginger_island_regions_and_connections(regions_by_name: Dict[str, Regi
return connections, regions_by_name return connections, regions_by_name
def modify_connections_for_mods(connections: Dict[str, ConnectionData], mods) -> Dict[str, ConnectionData]: def modify_connections_for_mods(connections: Dict[str, ConnectionData], mods: Iterable) -> Dict[str, ConnectionData]:
if mods is None: for mod in mods:
return connections
for mod in mods.value:
if mod not in ModDataList: if mod not in ModDataList:
continue continue
if mod in vanilla_connections_to_remove_by_mod: if mod in vanilla_connections_to_remove_by_mod:

View File

@ -441,6 +441,16 @@ def setup_multiworld(test_options: Iterable[Dict[str, int]] = None, seed=None) -
for i in range(1, len(test_options) + 1): for i in range(1, len(test_options) + 1):
multiworld.game[i] = StardewValleyWorld.game multiworld.game[i] = StardewValleyWorld.game
multiworld.player_name.update({i: f"Tester{i}"}) multiworld.player_name.update({i: f"Tester{i}"})
args = create_args(test_options)
multiworld.set_options(args)
for step in gen_steps:
call_all(multiworld, step)
return multiworld
def create_args(test_options):
args = Namespace() args = Namespace()
for name, option in StardewValleyWorld.options_dataclass.type_hints.items(): for name, option in StardewValleyWorld.options_dataclass.type_hints.items():
options = {} options = {}
@ -449,9 +459,4 @@ def setup_multiworld(test_options: Iterable[Dict[str, int]] = None, seed=None) -
value = option(player_options[name]) if name in player_options else option.from_any(option.default) value = option(player_options[name]) if name in player_options else option.from_any(option.default)
options.update({i: value}) options.update({i: value})
setattr(args, name, options) setattr(args, name, options)
multiworld.set_options(args) return args
for step in gen_steps:
call_all(multiworld, step)
return multiworld

View File

@ -37,7 +37,7 @@ class TestRaccoonBundlesLogic(SVTestBase):
options.BundlePrice: options.BundlePrice.option_normal, options.BundlePrice: options.BundlePrice.option_normal,
options.Craftsanity: options.Craftsanity.option_all, options.Craftsanity: options.Craftsanity.option_all,
} }
seed = 1234 # Magic seed that does what I want. Might need to get changed if we change the randomness behavior of raccoon bundles seed = 2 # Magic seed that does what I want. Might need to get changed if we change the randomness behavior of raccoon bundles
def test_raccoon_bundles_rely_on_previous_ones(self): def test_raccoon_bundles_rely_on_previous_ones(self):
# The first raccoon bundle is a fishing one # The first raccoon bundle is a fishing one

View File

@ -1,6 +1,7 @@
import argparse import argparse
import json import json
from ...options import FarmType, EntranceRandomization
from ...test import setup_solo_multiworld, allsanity_mods_6_x_x from ...test import setup_solo_multiworld, allsanity_mods_6_x_x
if __name__ == "__main__": if __name__ == "__main__":
@ -10,21 +11,23 @@ if __name__ == "__main__":
args = parser.parse_args() args = parser.parse_args()
seed = args.seed seed = args.seed
multi_world = setup_solo_multiworld( options = allsanity_mods_6_x_x()
allsanity_mods_6_x_x(), options[FarmType.internal_name] = FarmType.option_standard
seed=seed options[EntranceRandomization.internal_name] = EntranceRandomization.option_buildings
) multi_world = setup_solo_multiworld(options, seed=seed)
world = multi_world.worlds[1]
output = { output = {
"bundles": { "bundles": {
bundle_room.name: { bundle_room.name: {
bundle.name: str(bundle.items) bundle.name: str(bundle.items)
for bundle in bundle_room.bundles for bundle in bundle_room.bundles
} }
for bundle_room in multi_world.worlds[1].modified_bundles for bundle_room in world.modified_bundles
}, },
"items": [item.name for item in multi_world.get_items()], "items": [item.name for item in multi_world.get_items()],
"location_rules": {location.name: repr(location.access_rule) for location in multi_world.get_locations(1)} "location_rules": {location.name: repr(location.access_rule) for location in multi_world.get_locations(1)},
"slot_data": world.fill_slot_data()
} }
print(json.dumps(output)) print(json.dumps(output))

View File

@ -24,8 +24,7 @@ class TestGenerationIsStable(SVTestCase):
if self.skip_long_tests: if self.skip_long_tests:
raise unittest.SkipTest("Long tests disabled") raise unittest.SkipTest("Long tests disabled")
# seed = get_seed(33778671150797368040) # troubleshooting seed seed = get_seed()
seed = get_seed(74716545478307145559)
output_a = subprocess.check_output([sys.executable, '-m', 'worlds.stardew_valley.test.stability.StabilityOutputScript', '--seed', str(seed)]) output_a = subprocess.check_output([sys.executable, '-m', 'worlds.stardew_valley.test.stability.StabilityOutputScript', '--seed', str(seed)])
output_b = subprocess.check_output([sys.executable, '-m', 'worlds.stardew_valley.test.stability.StabilityOutputScript', '--seed', str(seed)]) output_b = subprocess.check_output([sys.executable, '-m', 'worlds.stardew_valley.test.stability.StabilityOutputScript', '--seed', str(seed)])
@ -54,3 +53,6 @@ class TestGenerationIsStable(SVTestCase):
# We check that the actual rule has the same order to make sure it is evaluated in the same order, # We check that the actual rule has the same order to make sure it is evaluated in the same order,
# so performance tests are repeatable as much as possible. # so performance tests are repeatable as much as possible.
self.assertEqual(rule_a, rule_b, f"Location rule of {location_a} at index {i} is different between both executions. Seed={seed}") self.assertEqual(rule_a, rule_b, f"Location rule of {location_a} at index {i} is different between both executions. Seed={seed}")
for key, value in result_a["slot_data"].items():
self.assertEqual(value, result_b["slot_data"][key], f"Slot data {key} is different between both executions. Seed={seed}")

View File

@ -0,0 +1,52 @@
import unittest
from unittest.mock import Mock
from .. import SVTestBase, create_args, allsanity_mods_6_x_x
from ... import STARDEW_VALLEY, FarmType, BundleRandomization, EntranceRandomization
class TestUniversalTrackerGenerationIsStable(SVTestBase):
options = allsanity_mods_6_x_x()
options.update({
EntranceRandomization.internal_name: EntranceRandomization.option_buildings,
BundleRandomization.internal_name: BundleRandomization.option_shuffled,
FarmType.internal_name: FarmType.option_standard, # Need to choose one otherwise it's random
})
def test_all_locations_and_items_are_the_same_between_two_generations(self):
# This might open a kivy window temporarily, but it's the only way to test this...
if self.skip_long_tests:
raise unittest.SkipTest("Long tests disabled")
try:
# This test only run if UT is present, so no risk of running in the CI.
from worlds.tracker.TrackerClient import TrackerGameContext # noqa
except ImportError:
raise unittest.SkipTest("UT not loaded, skipping test")
slot_data = self.world.fill_slot_data()
ut_data = self.world.interpret_slot_data(slot_data)
fake_context = Mock()
fake_context.re_gen_passthrough = {STARDEW_VALLEY: ut_data}
args = create_args({0: self.options})
args.outputpath = None
args.outputname = None
args.multi = 1
args.race = None
args.plando_options = self.multiworld.plando_options
args.plando_items = self.multiworld.plando_items
args.plando_texts = self.multiworld.plando_texts
args.plando_connections = self.multiworld.plando_connections
args.game = self.multiworld.game
args.name = self.multiworld.player_name
args.sprite = {}
args.sprite_pool = {}
args.skip_output = True
generated_multi_world = TrackerGameContext.TMain(fake_context, args, self.multiworld.seed)
generated_slot_data = generated_multi_world.worlds[1].fill_slot_data()
# Just checking slot data should prove that UT generates the same result as AP generation.
self.maxDiff = None
self.assertEqual(slot_data, generated_slot_data)