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:
parent
cc22161644
commit
9d36ad0df2
|
@ -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,
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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))
|
||||||
|
|
|
@ -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}")
|
||||||
|
|
|
@ -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)
|
Loading…
Reference in New Issue