From ce2553a2b3cd222a613b7b11207983c4d9668f7b Mon Sep 17 00:00:00 2001 From: toasterparty Date: Fri, 17 Feb 2023 00:21:56 -0800 Subject: [PATCH] [OC2] Location Balancing (#1458) --- worlds/overcooked2/Options.py | 26 ++++++++ worlds/overcooked2/__init__.py | 105 ++++++++++++++++++++++++++++++--- 2 files changed, 122 insertions(+), 9 deletions(-) diff --git a/worlds/overcooked2/Options.py b/worlds/overcooked2/Options.py index 8ab45104..d0de7f4c 100644 --- a/worlds/overcooked2/Options.py +++ b/worlds/overcooked2/Options.py @@ -1,13 +1,36 @@ +from enum import Enum from typing import TypedDict from Options import DefaultOnToggle, Range, Choice +class LocationBalancingMode(Enum): + disabled = 0 + compromise = 1 + full = 2 + + class OC2OnToggle(DefaultOnToggle): @property def result(self) -> bool: return bool(self.value) +class LocationBalancing(Choice): + """Location balancing affects the density of progression items found in your world relative to other wordlds. This setting changes nothing for solo games. + + - Disabled: Location density in your world can fluctuate greatly depending on the settings of other players. In extreme cases, your world may be entirely populated with filler items + + - Compromise: Locations are balanced to a midpoint between "fair" and "natural" + + - Full: Locations are balanced in an attempt to make the number of progression items sent out and received equal over the entire game""" + auto_display_name = True + display_name = "Location Balancing" + option_disabled = LocationBalancingMode.disabled.value + option_compromise = LocationBalancingMode.compromise.value + option_full = LocationBalancingMode.full.value + default = LocationBalancingMode.compromise.value + + class AlwaysServeOldestOrder(OC2OnToggle): """Modifies the game so that serving an expired order doesn't target the ticket with the highest tip. This helps players dig out of a broken tip combo faster.""" @@ -105,6 +128,9 @@ class StarThresholdScale(Range): overcooked_options = { + # generator options + "location_balancing": LocationBalancing, + # randomization options "shuffle_level_order": ShuffleLevelOrder, "include_horde_levels": IncludeHordeLevels, diff --git a/worlds/overcooked2/__init__.py b/worlds/overcooked2/__init__.py index c255375e..ddf66330 100644 --- a/worlds/overcooked2/__init__.py +++ b/worlds/overcooked2/__init__.py @@ -1,12 +1,12 @@ from enum import Enum from typing import Callable, Dict, Any, List, Optional -from BaseClasses import ItemClassification, CollectionState, Region, Entrance, Location, Tutorial +from BaseClasses import ItemClassification, CollectionState, Region, Entrance, Location, Tutorial, LocationProgressType from worlds.AutoWorld import World, WebWorld from .Overcooked2Levels import Overcooked2Level, Overcooked2GenericLevel, ITEMS_TO_EXCLUDE_IF_NO_DLC from .Locations import Overcooked2Location, oc2_location_name_to_id, oc2_location_id_to_name -from .Options import overcooked_options, OC2Options, OC2OnToggle +from .Options import overcooked_options, OC2Options, OC2OnToggle, LocationBalancingMode from .Items import item_table, Overcooked2Item, item_name_to_id, item_id_to_name, item_to_unlock_event, item_frequencies from .Logic import has_requirements_for_level_star, has_requirements_for_level_access, level_shuffle_factory, is_item_progression, is_useful @@ -60,7 +60,6 @@ class Overcooked2World(World): options: Dict[str, Any] itempool: List[Overcooked2Item] - # Helper Functions def is_level_horde(self, level_id: int) -> bool: @@ -106,6 +105,7 @@ class Overcooked2World(World): level_id: int, stars: int, is_event: bool = False, + priority=False, ) -> None: if is_event: @@ -123,6 +123,11 @@ class Overcooked2World(World): location.event = is_event + if priority: + location.progress_type = LocationProgressType.PRIORITY + else: + location.progress_type = LocationProgressType.DEFAULT + # if level_id is none, then it's the 6-6 edge case if level_id is None: level_id = 36 @@ -145,6 +150,76 @@ class Overcooked2World(World): if issubclass(option, OC2OnToggle) else getattr(self.multiworld, name)[self.player].value for name, option in overcooked_options.items()}) + def get_n_random_locations(self, n: int) -> List[int]: + """Return a list of n random non-repeating level locations""" + levels = list() + + if n == 0: + return levels + + for level in Overcooked2Level(): + if level.level_id == 36: + continue + elif not self.options["KevinLevels"] and level.level_id > 36: + break + + levels.append(level.level_id) + + self.multiworld.random.shuffle(levels) + return levels[:n] + + def get_priority_locations(self) -> List[int]: + """Randomly generate list of priority locations, thus insulating this game + from the negative effects of being shuffled with games that contain large + ammounts of filler""" + + if self.multiworld.players == 1: + # random priority locations have no desirable effect on solo seeds + return list() + + balancing_mode = self.get_options()["LocationBalancing"] + + if balancing_mode == LocationBalancingMode.disabled.value: + # Location balancing is disabled, progression density is purely determined by filler + return list() + + # Count how many progression items are required for this overcooked instance + game_item_count = len(self.itempool) + game_progression_count = 0 + for item in self.itempool: + if item.classification == ItemClassification.progression: + game_progression_count += 1 + game_progression_density = game_progression_count/game_item_count + + if balancing_mode == LocationBalancingMode.full.value: + # Location balancing will be employed in an attempt to keep the number of + # progression locations and proression items as close to equal as possible + return self.get_n_random_locations(game_progression_count) + + assert balancing_mode == LocationBalancingMode.compromise.value + + # Count how many progression items are shuffled between all games + total_item_count = len(self.multiworld.itempool) + total_progression_count = 0 + + for item in self.multiworld.itempool: + if item.classification == ItemClassification.progression: + total_progression_count += 1 + total_progression_density = total_progression_count/total_item_count + + if total_progression_density >= game_progression_density: + # This game has more filler than the average of all other games. + # It is not in need of location balancing + return list() + + # Calculate the midpoint between the two ratios + target_progression_ratio = (game_progression_density - total_progression_density) / 2.0 + total_progression_density + target_progression_count = int((target_progression_ratio * game_item_count) + 0.5) # I'm sorry I round like an old person + + # Location balancing will be employed in an attempt to find a compromise at + # the half-way point between natural probability and full artifical balancing + return self.get_n_random_locations(target_progression_count) + # Helper Data level_unlock_counts: Dict[int, int] # level_id, stars to purchase @@ -173,12 +248,20 @@ class Overcooked2World(World): else: self.level_mapping = None + def set_location_priority(self) -> None: + for level in Overcooked2Level(): + if level.level_id in self.get_priority_locations(): + location: Location = self.multiworld.get_location(level.location_name_item, self.player) + location.progress_type = LocationProgressType.PRIORITY + + def create_regions(self) -> None: # Menu -> Overworld self.add_region("Menu") self.add_region("Overworld") self.connect_regions("Menu", "Overworld") + # Create and populate "regions" (a.k.a. levels) for level in Overcooked2Level(): if not self.options["KevinLevels"] and level.level_id > 36: break @@ -249,6 +332,7 @@ class Overcooked2World(World): state.has("Victory", self.player) self.multiworld.completion_condition[self.player] = completion_condition + def create_items(self): self.itempool = [] @@ -264,17 +348,15 @@ class Overcooked2World(World): if not self.options["IncludeHordeLevels"] and item_name in ["Calmer Unbread", "Coin Purse"]: # skip horde-specific items if no horde levels continue - + if not self.options["KevinLevels"] and item_name.startswith("Kevin"): # skip kevin items if no kevin levels continue if is_item_progression(item_name, self.level_mapping, self.options["KevinLevels"]): - # print(f"{item_name} is progression") # progression.append(item_name) classification = ItemClassification.progression else: - # print(f"{item_name} is filler") if (is_useful(item_name)): # useful.append(item_name) classification = ItemClassification.useful @@ -306,10 +388,8 @@ class Overcooked2World(World): self.multiworld.itempool += self.itempool - def set_rules(self): - pass - def generate_basic(self) -> None: + def place_events(self): # Add Events (Star Acquisition) for level in Overcooked2Level(): if not self.options["KevinLevels"] and level.level_id > 36: @@ -330,6 +410,13 @@ class Overcooked2World(World): # Add Victory Condition self.place_event("6-6 Completed", "Victory") + def set_rules(self): + pass + + def generate_basic(self) -> None: + self.place_events() + self.set_location_priority() + # Items get distributed to locations def fill_json_data(self) -> Dict[str, Any]: