Skip to content

Commit

Permalink
Stardew Valley: Properly support Universal Tracker (ArchipelagoMW#3630)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
Jouramie authored Jul 26, 2024
1 parent cc22161 commit 9d36ad0
Show file tree
Hide file tree
Showing 7 changed files with 105 additions and 26 deletions.
28 changes: 23 additions & 5 deletions worlds/stardew_valley/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -27,15 +28,20 @@
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):
Expand All @@ -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()}
Expand Down Expand Up @@ -95,6 +101,17 @@ def __init__(self, multiworld: MultiWorld, player: int):
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)
Expand All @@ -108,12 +125,12 @@ def force_change_options_if_incompatible(self):
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):
Expand Down Expand Up @@ -413,6 +430,7 @@ def fill_slot_data(self) -> Dict[str, Any]:
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,
Expand Down
11 changes: 5 additions & 6 deletions worlds/stardew_valley/regions.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,8 @@ def __call__(self, name: str, regions: Iterable[str]) -> Region:
[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),
Expand Down Expand Up @@ -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)

Expand All @@ -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:
Expand Down
17 changes: 11 additions & 6 deletions worlds/stardew_valley/test/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}
Expand All @@ -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
2 changes: 1 addition & 1 deletion worlds/stardew_valley/test/rules/TestBundles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 9 additions & 6 deletions worlds/stardew_valley/test/stability/StabilityOutputScript.py
Original file line number Diff line number Diff line change
@@ -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__":
Expand All @@ -10,21 +11,23 @@
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))
Expand Down
6 changes: 4 additions & 2 deletions worlds/stardew_valley/test/stability/TestStability.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,7 @@ def test_all_locations_and_items_are_the_same_between_two_generations(self):
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)])
Expand Down Expand Up @@ -54,3 +53,6 @@ def test_all_locations_and_items_are_the_same_between_two_generations(self):
# 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}")
52 changes: 52 additions & 0 deletions worlds/stardew_valley/test/stability/TestUniversalTracker.py
Original file line number Diff line number Diff line change
@@ -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)

0 comments on commit 9d36ad0

Please sign in to comment.