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
from random import Random
from typing import Dict, Any, Iterable, Optional, Union, List, TextIO
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.region_names import Region as RegionName, LogicRegion
logger = logging.getLogger(__name__)
STARDEW_VALLEY = "Stardew Valley"
UNIVERSAL_TRACKER_SEED_PROPERTY = "ut_seed"
client_version = 0
class StardewLocation(Location):
game: str = "Stardew Valley"
game: str = STARDEW_VALLEY
class StardewItem(Item):
game: str = "Stardew Valley"
game: str = STARDEW_VALLEY
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,
befriend villagers, and uncover dark secrets.
"""
game = "Stardew Valley"
game = STARDEW_VALLEY
topology_present = False
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.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):
self.force_change_options_if_incompatible()
self.content = create_content(self.options)
@ -108,12 +125,12 @@ class StardewValleyWorld(World):
self.options.exclude_ginger_island.value = ExcludeGingerIsland.option_false
goal_name = self.options.goal.current_key
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})")
if exclude_ginger_island and self.options.walnutsanity != Walnutsanity.preset_none:
self.options.walnutsanity.value = Walnutsanity.preset_none
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")
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]
slot_data = self.options.as_dict(*included_option_names)
slot_data.update({
UNIVERSAL_TRACKER_SEED_PROPERTY: self.seed,
"seed": self.random.randrange(1000000000), # Seed should be max 9 digits
"randomized_entrances": self.randomized_entrances,
"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_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,
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),
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),
@ -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]]:
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 = 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
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
def modify_connections_for_mods(connections: Dict[str, ConnectionData], mods) -> Dict[str, ConnectionData]:
if mods is None:
return connections
for mod in mods.value:
def modify_connections_for_mods(connections: Dict[str, ConnectionData], mods: Iterable) -> Dict[str, ConnectionData]:
for mod in mods:
if mod not in ModDataList:
continue
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):
multiworld.game[i] = StardewValleyWorld.game
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()
for name, option in StardewValleyWorld.options_dataclass.type_hints.items():
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)
options.update({i: value})
setattr(args, name, options)
multiworld.set_options(args)
for step in gen_steps:
call_all(multiworld, step)
return multiworld
return args

View File

@ -37,7 +37,7 @@ class TestRaccoonBundlesLogic(SVTestBase):
options.BundlePrice: options.BundlePrice.option_normal,
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):
# The first raccoon bundle is a fishing one

View File

@ -1,6 +1,7 @@
import argparse
import json
from ...options import FarmType, EntranceRandomization
from ...test import setup_solo_multiworld, allsanity_mods_6_x_x
if __name__ == "__main__":
@ -10,21 +11,23 @@ if __name__ == "__main__":
args = parser.parse_args()
seed = args.seed
multi_world = setup_solo_multiworld(
allsanity_mods_6_x_x(),
seed=seed
)
options = allsanity_mods_6_x_x()
options[FarmType.internal_name] = FarmType.option_standard
options[EntranceRandomization.internal_name] = EntranceRandomization.option_buildings
multi_world = setup_solo_multiworld(options, seed=seed)
world = multi_world.worlds[1]
output = {
"bundles": {
bundle_room.name: {
bundle.name: str(bundle.items)
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()],
"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))

View File

@ -24,8 +24,7 @@ class TestGenerationIsStable(SVTestCase):
if self.skip_long_tests:
raise unittest.SkipTest("Long tests disabled")
# seed = get_seed(33778671150797368040) # troubleshooting seed
seed = get_seed(74716545478307145559)
seed = get_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)])
@ -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,
# 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}")
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)