From cae1e683e2934e63806bdaa480c84c4f8f8eb5e8 Mon Sep 17 00:00:00 2001 From: kindasneaki Date: Sun, 5 Feb 2023 13:51:03 -0700 Subject: [PATCH] RoR2: 1.20 content update (#1396) ## Adding in Explore Mode: Features include: * Added in `environments` to be items. * `Location checks` are now `environment based` instead of being able to get them from anywhere. * Added in support for the `DLC Survivors of the void` which include `Void Items` and `3 new maps` that come with it. (option added to use DLC) --------- Co-authored-by: Dogpetkid --- worlds/ror2/Items.py | 47 +++++- worlds/ror2/Locations.py | 116 ++++++++++++++- worlds/ror2/Options.py | 206 +++++++++++++++++++++----- worlds/ror2/Regions.py | 126 ++++++++++++++++ worlds/ror2/RoR2Environments.py | 118 +++++++++++++++ worlds/ror2/Rules.py | 173 +++++++++++++++++---- worlds/ror2/__init__.py | 181 +++++++++++++++++----- worlds/ror2/docs/en_Risk of Rain 2.md | 57 ++++++- 8 files changed, 908 insertions(+), 116 deletions(-) create mode 100644 worlds/ror2/Regions.py create mode 100644 worlds/ror2/RoR2Environments.py diff --git a/worlds/ror2/Items.py b/worlds/ror2/Items.py index 9efbc713..874b2a7c 100644 --- a/worlds/ror2/Items.py +++ b/worlds/ror2/Items.py @@ -1,13 +1,13 @@ -from typing import Dict from BaseClasses import Item from .Options import ItemWeights +from .RoR2Environments import * class RiskOfRainItem(Item): game: str = "Risk of Rain 2" -# 37000 - 38000 +# 37000 - 37699, 38000 item_table: Dict[str, int] = { "Dio's Best Friend": 37001, "Common Item": 37002, @@ -19,9 +19,24 @@ item_table: Dict[str, int] = { "Item Scrap, White": 37008, "Item Scrap, Green": 37009, "Item Scrap, Red": 37010, - "Item Scrap, Yellow": 37011 + "Item Scrap, Yellow": 37011, + "Void Item": 37012 } +# 37700 - 37699 +################################################## +# environments + +environment_offest = 37700 + +# add ALL environments into the item table +environment_offset_table = shift_by_offset(environment_ALL_table, environment_offest) +item_table.update(shift_by_offset(environment_ALL_table, environment_offest)) +# use the sotv dlc in the item table so that all names can be looked up regardless of use + +# end of environments +################################################## + default_weights: Dict[str, int] = { "Item Scrap, Green": 16, "Item Scrap, Red": 4, @@ -32,6 +47,7 @@ default_weights: Dict[str, int] = { "Legendary Item": 8, "Boss Item": 4, "Lunar Item": 16, + "Void Item": 16, "Equipment": 32 } @@ -45,6 +61,7 @@ new_weights: Dict[str, int] = { "Legendary Item": 10, "Boss Item": 5, "Lunar Item": 10, + "Void Item": 16, "Equipment": 20 } @@ -58,6 +75,7 @@ uncommon_weights: Dict[str, int] = { "Legendary Item": 10, "Boss Item": 5, "Lunar Item": 15, + "Void Item": 16, "Equipment": 20 } @@ -71,6 +89,7 @@ legendary_weights: Dict[str, int] = { "Legendary Item": 100, "Boss Item": 5, "Lunar Item": 15, + "Void Item": 16, "Equipment": 20 } @@ -84,6 +103,7 @@ lunartic_weights: Dict[str, int] = { "Legendary Item": 0, "Boss Item": 0, "Lunar Item": 100, + "Void Item": 0, "Equipment": 0 } @@ -97,6 +117,7 @@ chaos_weights: Dict[str, int] = { "Legendary Item": 30, "Boss Item": 20, "Lunar Item": 60, + "Void Item": 60, "Equipment": 40 } @@ -110,6 +131,7 @@ no_scraps_weights: Dict[str, int] = { "Legendary Item": 15, "Boss Item": 5, "Lunar Item": 10, + "Void Item": 16, "Equipment": 25 } @@ -123,6 +145,7 @@ even_weights: Dict[str, int] = { "Legendary Item": 1, "Boss Item": 1, "Lunar Item": 1, + "Void Item": 1, "Equipment": 1 } @@ -136,6 +159,21 @@ scraps_only: Dict[str, int] = { "Legendary Item": 0, "Boss Item": 0, "Lunar Item": 0, + "Void Item": 0, + "Equipment": 0 +} + +void_weights: Dict[str, int] = { + "Item Scrap, Green": 0, + "Item Scrap, Red": 0, + "Item Scrap, Yellow": 0, + "Item Scrap, White": 0, + "Common Item": 0, + "Uncommon Item": 0, + "Legendary Item": 0, + "Boss Item": 0, + "Lunar Item": 0, + "Void Item": 100, "Equipment": 0 } @@ -148,7 +186,8 @@ item_pool_weights: Dict[int, Dict[str, int]] = { ItemWeights.option_chaos: chaos_weights, ItemWeights.option_no_scraps: no_scraps_weights, ItemWeights.option_even: even_weights, - ItemWeights.option_scraps_only: scraps_only + ItemWeights.option_scraps_only: scraps_only, + ItemWeights.option_void: void_weights, } lookup_id_to_name: Dict[int, str] = {id: name for name, id in item_table.items()} diff --git a/worlds/ror2/Locations.py b/worlds/ror2/Locations.py index e4ebe8dd..7db3ceca 100644 --- a/worlds/ror2/Locations.py +++ b/worlds/ror2/Locations.py @@ -1,13 +1,119 @@ -from typing import Dict +from typing import Tuple from BaseClasses import Location from .Options import TotalLocations +from .Options import ChestsPerEnvironment +from .Options import ShrinesPerEnvironment +from .Options import ScavengersPerEnvironment +from .Options import ScannersPerEnvironment +from .Options import AltarsPerEnvironment +from .RoR2Environments import * class RiskOfRainLocation(Location): game: str = "Risk of Rain 2" -# 37006 - 37506 -item_pickups: Dict[str, int] = { - f"ItemPickup{i+1}": 37000+i for i in range(TotalLocations.range_end) -} +ror2_locations_start_id = 38000 + + +def get_classic_item_pickups(n: int) -> Dict[str, int]: + """Get n ItemPickups, capped at the max value for TotalLocations""" + n = max(n, 0) + n = min(n, TotalLocations.range_end) + return { f"ItemPickup{i+1}": ror2_locations_start_id+i for i in range(n) } + + +item_pickups = get_classic_item_pickups(TotalLocations.range_end) +location_table = item_pickups + + +def environment_abreviation(long_name:str) -> str: + """convert long environment names to initials""" + abrev = "" + # go through every word finding a letter (or number) for an initial + for word in long_name.split(): + initial = word[0] + for letter in word: + if letter.isalnum(): + initial = letter + break + abrev+= initial + return abrev + +# highest numbered orderedstages (this is so we can treat the easily caculate the check ids based on the environment and location "offset") +highest_orderedstage: int= max(compress_dict_list_horizontal(environment_orderedstages_table).values()) + +ror2_locations_start_orderedstage = ror2_locations_start_id + TotalLocations.range_end + +class orderedstage_location: + """A class to behave like a struct for storing the offsets of location types in the allocated space per orderedstage environments.""" + # TODO is there a better, more generic way to do this? + offset_ChestsPerEnvironment = 0 + offset_ShrinesPerEnvironment = offset_ChestsPerEnvironment + ChestsPerEnvironment.range_end + offset_ScavengersPerEnvironment = offset_ShrinesPerEnvironment + ShrinesPerEnvironment.range_end + offset_ScannersPerEnvironment = offset_ScavengersPerEnvironment + ScavengersPerEnvironment.range_end + offset_AltarsPerEnvironment = offset_ScannersPerEnvironment + ScannersPerEnvironment.range_end + + # total space allocated to the locations in a single orderedstage environment + allocation = offset_AltarsPerEnvironment + AltarsPerEnvironment.range_end + + def get_environment_locations(chests:int, shrines:int, scavengers:int, scanners:int, altars:int, environment: Tuple[str, int]) -> Dict[str, int]: + """Get the locations within a specific environment""" + environment_name = environment[0] + environment_index = environment[1] + locations = {} + + # due to this mapping, since environment ids are not consecutive, there are lots of "wasted" id numbers + # TODO perhaps a hashing algorithm could be used to compress this range and save "wasted" ids + environment_start_id = environment_index * orderedstage_location.allocation + ror2_locations_start_orderedstage + for n in range(chests): + locations.update({f"{environment_name}: Chest {n+1}": n + orderedstage_location.offset_ChestsPerEnvironment + environment_start_id}) + for n in range(shrines): + locations.update({f"{environment_name}: Shrine {n+1}": n + orderedstage_location.offset_ShrinesPerEnvironment + environment_start_id}) + for n in range(scavengers): + locations.update({f"{environment_name}: Scavenger {n+1}": n + orderedstage_location.offset_ScavengersPerEnvironment + environment_start_id}) + for n in range(scanners): + locations.update({f"{environment_name}: Radio Scanner {n+1}": n + orderedstage_location.offset_ScannersPerEnvironment + environment_start_id}) + for n in range(altars): + locations.update({f"{environment_name}: Newt Altar {n+1}": n + orderedstage_location.offset_AltarsPerEnvironment + environment_start_id}) + return locations + + def get_locations(chests:int, shrines:int, scavengers:int, scanners:int, altars:int, dlc_sotv:bool) -> Dict[str, int]: + """Get a dictionary of locations for the ordedstage environments with the locations from the parameters.""" + locations = {} + orderedstages = compress_dict_list_horizontal(environment_vanilla_orderedstages_table) + if(dlc_sotv): orderedstages.update(compress_dict_list_horizontal(environment_sotv_orderedstages_table)) + # for every environment, generate the respective locations + for environment_name, environment_index in orderedstages.items(): + # locations = locations | orderedstage_location.get_environment_locations( + locations.update(orderedstage_location.get_environment_locations( + chests=chests, + shrines=shrines, + scavengers=scavengers, + scanners=scanners, + altars=altars, + environment=(environment_name, environment_index) + )) + return locations + + def getall_locations(dlc_sotv:bool=True) -> Dict[str, int]: + """ + Get all locations in ordered stages. + Set dlc_sotv to true for the SOTV DLC to be included. + """ + # to get all locations, attempt using as many locations as possible + return orderedstage_location.get_locations( + chests=ChestsPerEnvironment.range_end, + shrines=ShrinesPerEnvironment.range_end, + scavengers=ScavengersPerEnvironment.range_end, + scanners=ScannersPerEnvironment.range_end, + altars=AltarsPerEnvironment.range_end, + dlc_sotv=dlc_sotv + ) + + +ror2_location_post_orderedstage = ror2_locations_start_orderedstage + highest_orderedstage*orderedstage_location.allocation +location_table.update(orderedstage_location.getall_locations()) +# use the sotv dlc in the lookup table so that all ids can be looked up regardless of use + +lookup_id_to_name: Dict[int, str] = {id: name for name, id in location_table.items()} diff --git a/worlds/ror2/Options.py b/worlds/ror2/Options.py index a95cbf59..8876a4e4 100644 --- a/worlds/ror2/Options.py +++ b/worlds/ror2/Options.py @@ -1,31 +1,99 @@ from typing import Dict -from Options import Option, DefaultOnToggle, Range, Choice +from Options import Option, Toggle, DefaultOnToggle, DeathLink, Range, Choice + + +# NOTE be aware that since the range of item ids that RoR2 uses is based off of the maximums of checks +# Be careful when changing the range_end values not to go into another game's IDs +# NOTE that these changes to range_end must also be reflected in the RoR2 client so it understands the same ids. + +class Goal(Choice): + """ + Classic Mode: Every Item pickup increases fills a progress bar which gives location checks. + + Explore Mode: Each environment will have location checks within each environment. + environments will be locked in the item pool until received. + """ + display_name = "Game Mode" + option_classic = 0 + option_explore = 1 + default = 0 class TotalLocations(Range): - """Number of location checks which are added to the Risk of Rain playthrough.""" + """Classic Mode: Number of location checks which are added to the Risk of Rain playthrough.""" display_name = "Total Locations" - range_start = 10 + range_start = 40 range_end = 250 - default = 20 + default = 40 +class ChestsPerEnvironment(Range): + """Explore Mode: The number of chest locations per environment.""" + display_name = "Chests per Environment" + range_start = 2 + range_end = 20 + default = 10 + + +class ShrinesPerEnvironment(Range): + """Explore Mode: The number of shrine locations per environment.""" + display_name = "Shrines per Environment" + range_start = 2 + range_end = 20 + default = 5 + + +class ScavengersPerEnvironment(Range): + """Explore Mode: The number of scavenger locations per environment.""" + display_name = "Scavenger per Environment" + range_start = 0 + range_end = 1 + default = 1 + +class ScannersPerEnvironment(Range): + """Explore Mode: The number of scanners locations per environment.""" + display_name = "Radio Scanners per Environment" + range_start = 0 + range_end = 1 + default = 1 + +class AltarsPerEnvironment(Range): + """Explore Mode: The number of altars locations per environment.""" + display_name = "Newts Per Environment" + range_start = 0 + range_end = 2 + default = 1 + class TotalRevivals(Range): """Total Percentage of `Dio's Best Friend` item put in the item pool.""" - display_name = "Total Percentage Revivals Available" + display_name = "Total Revives" range_start = 0 range_end = 10 default = 4 class ItemPickupStep(Range): - """Number of items to pick up before an AP Check is completed. + """ + Number of items to pick up before an AP Check is completed. Setting to 1 means every other pickup. - Setting to 2 means every third pickup. So on...""" + Setting to 2 means every third pickup. So on... + """ display_name = "Item Pickup Step" range_start = 0 range_end = 5 - default = 2 + default = 1 + +class ShrineUseStep(Range): + """ + Explore Mode: + Number of shrines to use up before an AP Check is completed. + Setting to 1 means every other pickup. + Setting to 2 means every third pickup. So on... + """ + display_name = "Shrine use Step" + range_start = 0 + range_end = 3 + default = 0 class AllowLunarItems(DefaultOnToggle): @@ -38,13 +106,33 @@ class StartWithRevive(DefaultOnToggle): display_name = "Start with a Revive" -class FinalStageDeath(DefaultOnToggle): +class FinalStageDeath(Toggle): """Death on the final boss stage counts as a win.""" display_name = "Final Stage Death is Win" +class BeginWithLoop(Toggle): + """ + Enable to precollect a full loop of environments. + Only has an effect with Explore Mode. + """ + display_name = "Begin With Loop" + + +class DLC_SOTV(Toggle): + """ + Enable if you are using SOTV DLC. + Affects environment availability for Explore Mode. + Adds Void Items into the item pool + """ + display_name = "Enable DLC - SOTV" + + + class GreenScrap(Range): - """Weight of Green Scraps in the item pool. (Ignored unless Item Weight Presets is 'No')""" + """Weight of Green Scraps in the item pool. + + (Ignored unless Item Weight Presets is 'No')""" display_name = "Green Scraps" range_start = 0 range_end = 100 @@ -52,7 +140,9 @@ class GreenScrap(Range): class RedScrap(Range): - """Weight of Red Scraps in the item pool. (Ignored unless Item Weight Presets is 'No')""" + """Weight of Red Scraps in the item pool. + + (Ignored unless Item Weight Presets is 'No')""" display_name = "Red Scraps" range_start = 0 range_end = 100 @@ -60,7 +150,9 @@ class RedScrap(Range): class YellowScrap(Range): - """Weight of yellow scraps in the item pool. (Ignored unless Item Weight Presets is 'No')""" + """Weight of yellow scraps in the item pool. + + (Ignored unless Item Weight Presets is 'No')""" display_name = "Yellow Scraps" range_start = 0 range_end = 100 @@ -68,7 +160,9 @@ class YellowScrap(Range): class WhiteScrap(Range): - """Weight of white scraps in the item pool. (Ignored unless Item Weight Presets is 'No')""" + """Weight of white scraps in the item pool. + + (Ignored unless Item Weight Presets is 'No')""" display_name = "White Scraps" range_start = 0 range_end = 100 @@ -76,7 +170,9 @@ class WhiteScrap(Range): class CommonItem(Range): - """Weight of common items in the item pool. (Ignored unless Item Weight Presets is 'No')""" + """Weight of common items in the item pool. + + (Ignored unless Item Weight Presets is 'No')""" display_name = "Common Items" range_start = 0 range_end = 100 @@ -84,7 +180,9 @@ class CommonItem(Range): class UncommonItem(Range): - """Weight of uncommon items in the item pool. (Ignored unless Item Weight Presets is 'No')""" + """Weight of uncommon items in the item pool. + + (Ignored unless Item Weight Presets is 'No')""" display_name = "Uncommon Items" range_start = 0 range_end = 100 @@ -92,7 +190,9 @@ class UncommonItem(Range): class LegendaryItem(Range): - """Weight of legendary items in the item pool. (Ignored unless Item Weight Presets is 'No')""" + """Weight of legendary items in the item pool. + + (Ignored unless Item Weight Presets is 'No')""" display_name = "Legendary Items" range_start = 0 range_end = 100 @@ -100,7 +200,9 @@ class LegendaryItem(Range): class BossItem(Range): - """Weight of boss items in the item pool. (Ignored unless Item Weight Presets is 'No')""" + """Weight of boss items in the item pool. + + (Ignored unless Item Weight Presets is 'No')""" display_name = "Boss Items" range_start = 0 range_end = 100 @@ -108,36 +210,54 @@ class BossItem(Range): class LunarItem(Range): - """Weight of lunar items in the item pool. (Ignored unless Item Weight Presets is 'No')""" + """Weight of lunar items in the item pool. + + (Ignored unless Item Weight Presets is 'No')""" display_name = "Lunar Items" range_start = 0 range_end = 100 default = 16 +class VoidItem(Range): + """Weight of void items in the item pool. + + (Ignored unless Item Weight Presets is 'No') + + (Ignored if Enable DLC - SOTV is 'No') """ + display_name = "Void Items" + range_start = 0 + range_end = 100 + default = 16 + + class Equipment(Range): - """Weight of equipment items in the item pool. (Ignored unless Item Weight Presets is 'No')""" + """Weight of equipment items in the item pool. + + (Ignored unless Item Weight Presets is 'No')""" display_name = "Equipment" range_start = 0 range_end = 100 default = 32 -class ItemPoolPresetToggle(DefaultOnToggle): +class ItemPoolPresetToggle(Toggle): """Will use the item weight presets when set to true, otherwise will use the custom set item pool weights.""" display_name = "Use Item Weight Presets" class ItemWeights(Choice): - """Preset choices for determining the weights of the item pool. - New is a test for a potential adjustment to the default weights. - Uncommon puts a large number of uncommon items in the pool. - Legendary puts a large number of legendary items in the pool. - Lunartic makes everything a lunar item. - Chaos generates the pool completely at random with rarer items having a slight cap to prevent this option being too easy. - No Scraps removes all scrap items from the item pool. - Even generates the item pool with every item having an even weight. - Scraps Only will be only scrap items in the item pool.""" + """Set item_pool_presets to true if you want to use one of these presets. + Preset choices for determining the weights of the item pool. + - New is a test for a potential adjustment to the default weights. + - Uncommon puts a large number of uncommon items in the pool. + - Legendary puts a large number of legendary items in the pool. + - Lunartic makes everything a lunar item. + - Chaos generates the pool completely at random with rarer items having a slight cap to prevent this option being too easy. + - No Scraps removes all scrap items from the item pool. + - Even generates the item pool with every item having an even weight. + - Scraps Only will be only scrap items in the item pool. + - Void makes everything a void item.""" display_name = "Item Weights" option_default = 0 option_new = 1 @@ -148,6 +268,7 @@ class ItemWeights(Choice): option_no_scraps = 6 option_even = 7 option_scraps_only = 8 + option_void = 9 # define a dictionary for the weights of the generated item pool. @@ -161,17 +282,28 @@ ror2_weights: Dict[str, type(Option)] = { "legendary_item": LegendaryItem, "boss_item": BossItem, "lunar_item": LunarItem, + "void_item": VoidItem, "equipment": Equipment } ror2_options: Dict[str, type(Option)] = { - "total_locations": TotalLocations, - "total_revivals": TotalRevivals, - "start_with_revive": StartWithRevive, - "final_stage_death": FinalStageDeath, - "item_pickup_step": ItemPickupStep, - "enable_lunar": AllowLunarItems, - "item_weights": ItemWeights, - "item_pool_presets": ItemPoolPresetToggle, + "goal": Goal, + "total_locations": TotalLocations, + "chests_per_stage": ChestsPerEnvironment, + "shrines_per_stage": ShrinesPerEnvironment, + "scavengers_per_stage": ScavengersPerEnvironment, + "scanner_per_stage": ScannersPerEnvironment, + "altars_per_stage": AltarsPerEnvironment, + "total_revivals": TotalRevivals, + "start_with_revive": StartWithRevive, + "final_stage_death": FinalStageDeath, + "begin_with_loop": BeginWithLoop, + "dlc_sotv": DLC_SOTV, + "death_link": DeathLink, + "item_pickup_step": ItemPickupStep, + "shrine_use_step": ShrineUseStep, + "enable_lunar": AllowLunarItems, + "item_weights": ItemWeights, + "item_pool_presets": ItemPoolPresetToggle, **ror2_weights } diff --git a/worlds/ror2/Regions.py b/worlds/ror2/Regions.py new file mode 100644 index 00000000..878d91ec --- /dev/null +++ b/worlds/ror2/Regions.py @@ -0,0 +1,126 @@ +from typing import Dict, List, NamedTuple, Optional + +from BaseClasses import MultiWorld, Region, RegionType, Entrance +from .Locations import location_table, RiskOfRainLocation + + +class RoRRegionData(NamedTuple): + locations: Optional[List[str]] + region_exits: Optional[List[str]] + + +def create_regions(multiworld: MultiWorld, player: int): + # Default Locations + non_dlc_regions: Dict[str, RoRRegionData] = { + "Menu": RoRRegionData(None, ["Distant Roost", "Distant Roost (2)", "Titanic Plains", "Titanic Plains (2)"]), + "Distant Roost": RoRRegionData([], ["OrderedStage_1"]), + "Distant Roost (2)": RoRRegionData([], ["OrderedStage_1"]), + "Titanic Plains": RoRRegionData([], ["OrderedStage_1"]), + "Titanic Plains (2)": RoRRegionData([], ["OrderedStage_1"]), + "Abandoned Aqueduct": RoRRegionData([], ["OrderedStage_2"]), + "Wetland Aspect": RoRRegionData([], ["OrderedStage_2"]), + "Rallypoint Delta": RoRRegionData([], ["OrderedStage_3"]), + "Scorched Acres": RoRRegionData([], ["OrderedStage_3"]), + "Abyssal Depths": RoRRegionData([], ["OrderedStage_4"]), + "Siren's Call": RoRRegionData([], ["OrderedStage_4"]), + "Sundered Grove": RoRRegionData([], ["OrderedStage_4"]), + "Sky Meadow": RoRRegionData([], ["Hidden Realm: Bulwark's Ambry", "OrderedStage_5"]), + } + # SOTV Regions + dlc_regions: Dict[str, RoRRegionData] = { + "Siphoned Forest": RoRRegionData([], ["OrderedStage_1"]), + "Aphelian Sanctuary": RoRRegionData([], ["OrderedStage_2"]), + "Sulfur Pools": RoRRegionData([], ["OrderedStage_3"]) + } + other_regions: Dict[str, RoRRegionData] = { + "Commencement": RoRRegionData(None, ["Victory"]), + "OrderedStage_5": RoRRegionData(None, ["Hidden Realm: A Moment, Fractured", "Commencement"]), + "OrderedStage_1": RoRRegionData(None, ["Hidden Realm: Bazaar Between Time", + "Hidden Realm: Gilded Coast", "Abandoned Aqueduct", "Wetland Aspect"]), + "OrderedStage_2": RoRRegionData(None, ["Rallypoint Delta", "Scorched Acres"]), + "OrderedStage_3": RoRRegionData(None, ["Abyssal Depths", "Siren's Call", "Sundered Grove"]), + "OrderedStage_4": RoRRegionData(None, ["Sky Meadow"]), + "Hidden Realm: A Moment, Fractured": RoRRegionData(None, ["Hidden Realm: A Moment, Whole"]), + "Hidden Realm: A Moment, Whole": RoRRegionData(None, ["Victory"]), + "Void Fields": RoRRegionData(None, []), + "Victory": RoRRegionData(None, None), + "Petrichor V": RoRRegionData(None, ["Victory"]), + "Hidden Realm: Bulwark's Ambry": RoRRegionData(None, None), + "Hidden Realm: Bazaar Between Time": RoRRegionData(None, ["Void Fields"]), + "Hidden Realm: Gilded Coast": RoRRegionData(None, None) + } + dlc_other_regions: Dict[str, RoRRegionData] = { + "The Planetarium": RoRRegionData(None, ["Victory"]), + "Void Locus": RoRRegionData(None, ["The Planetarium"]) + } + # Totals of each item + chests = int(multiworld.chests_per_stage[player]) + shrines = int(multiworld.shrines_per_stage[player]) + scavengers = int(multiworld.scavengers_per_stage[player]) + scanners = int(multiworld.scanner_per_stage[player]) + newt = int(multiworld.altars_per_stage[player]) + all_location_regions = {**non_dlc_regions} + if multiworld.dlc_sotv[player]: + all_location_regions = {**non_dlc_regions, **dlc_regions} + + # Locations + for key in all_location_regions: + if key == "Menu": + continue + # Chests + for i in range(0, chests): + all_location_regions[key].locations.append(f"{key}: Chest {i + 1}") + # Shrines + for i in range(0, shrines): + all_location_regions[key].locations.append(f"{key}: Shrine {i + 1}") + # Scavengers + if scavengers > 0: + for i in range(0, scavengers): + all_location_regions[key].locations.append(f"{key}: Scavenger {i + 1}") + # Radio Scanners + if scanners > 0: + for i in range(0, scanners): + all_location_regions[key].locations.append(f"{key}: Radio Scanner {i + 1}") + # Newt Altars + if newt > 0: + for i in range(0, newt): + all_location_regions[key].locations.append(f"{key}: Newt Altar {i + 1}") + regions_pool: Dict = {**all_location_regions, **other_regions} + + # DLC Locations + if multiworld.dlc_sotv[player]: + non_dlc_regions["Menu"].region_exits.append("Siphoned Forest") + other_regions["OrderedStage_2"].region_exits.append("Aphelian Sanctuary") + other_regions["OrderedStage_3"].region_exits.append("Sulfur Pools") + other_regions["Commencement"].region_exits.append("The Planetarium") + other_regions["Void Fields"].region_exits.append("Void Locus") + regions_pool: Dict = {**all_location_regions, **other_regions, **dlc_other_regions} + + # Create all the regions + for name, data in regions_pool.items(): + multiworld.regions.append(create_region(multiworld, player, name, data)) + + # Connect all the regions to their exits + for name, data in regions_pool.items(): + create_connections_in_regions(multiworld, player, name, data) + + +def create_region(multiworld: MultiWorld, player: int, name: str, data: RoRRegionData): + region = Region(name, RegionType.Generic, name, player, multiworld) + if data.locations: + for location_name in data.locations: + location_data = location_table.get(location_name) + location = RiskOfRainLocation(player, location_name, location_data, region) + region.locations.append(location) + + return region + + +def create_connections_in_regions(multiworld: MultiWorld, player: int, name: str, data: RoRRegionData): + region = multiworld.get_region(name, player) + if data.region_exits: + for region_exit in data.region_exits: + r_exit_stage = Entrance(player, region_exit, region) + exit_region = multiworld.get_region(region_exit, player) + r_exit_stage.connect(exit_region) + region.exits.append(r_exit_stage) diff --git a/worlds/ror2/RoR2Environments.py b/worlds/ror2/RoR2Environments.py new file mode 100644 index 00000000..43d2fe52 --- /dev/null +++ b/worlds/ror2/RoR2Environments.py @@ -0,0 +1,118 @@ +from typing import Dict, List, TypeVar + +# TODO probably move to Locations + +environment_vanilla_orderedstage_1_table: Dict[str, int] = { + "Distant Roost": 7, # blackbeach + "Distant Roost (2)": 8, # blackbeach2 + "Titanic Plains": 15, # golemplains + "Titanic Plains (2)": 16, # golemplains2 +} +environment_vanilla_orderedstage_2_table: Dict[str, int] = { + "Abandoned Aqueduct": 17, # goolake + "Wetland Aspect": 12, # foggyswamp +} +environment_vanilla_orderedstage_3_table: Dict[str, int] = { + "Rallypoint Delta": 13, # frozenwall + "Scorched Acres": 47, # wispgraveyard +} +environment_vanilla_orderedstage_4_table: Dict[str, int] = { + "Abyssal Depths": 10, # dampcavesimple + "Siren's Call": 37, # shipgraveyard + "Sundered Grove": 35, # rootjungle +} +environment_vanilla_orderedstage_5_table: Dict[str, int] = { + "Sky Meadow": 38, # skymeadow +} + +environment_vanilla_hidden_realm_table: Dict[str, int] = { + "Hidden Realm: Bulwark's Ambry": 5, # artifactworld + "Hidden Realm: Bazaar Between Time": 6, # bazaar + "Hidden Realm: Gilded Coast": 14, # goldshores + "Hidden Realm: A Moment, Whole": 27, # limbo + "Hidden Realm: A Moment, Fractured": 33, # mysteryspace +} + +environment_vanilla_special_table: Dict[str, int] = { + "Void Fields": 4, # arena + "Commencement": 32, # moon2 +} + +environment_sotv_orderedstage_1_table: Dict[str, int] = { + "Siphoned Forest": 39, # snowyforest +} +environment_sotv_orderedstage_2_table: Dict[str, int] = { + "Aphelian Sanctuary": 3, # ancientloft +} +environment_sotv_orderedstage_3_table: Dict[str, int] = { + "Sulfur Pools": 41, # sulfurpools +} +environment_sotv_orderedstage_4_table: Dict[str, int] = { } +environment_sotv_orderedstage_5_table: Dict[str, int] = { } + +# TODO idk much and idc much about simulacrum, is there a forced order or something? +environment_sotv_simulacrum_table: Dict[str, int] = { + "The Simulacrum (Aphelian Sanctuary)": 20, # itancientloft + "The Simulacrum (Abyssal Depths)": 21, # itdampcave + "The Simulacrum (Rallypoint Delta)": 22, # itfrozenwall + "The Simulacrum (Titanic Plains)": 23, # itgolemplains + "The Simulacrum (Abandoned Aqueduct)": 24, # itgoolake + "The Simulacrum (Commencement)": 25, # itmoon + "The Simulacrum (Sky Meadow)": 26, # itskymeadow +} + +environment_sotv_special_table: Dict[str, int] = { + "Void Locus": 45, # voidstage + "The Planetarium": 46, # voidraid +} + +X = TypeVar("X") +Y = TypeVar("Y") + + +def compress_dict_list_horizontal(list_of_dict: List[Dict[X, Y]]) -> Dict[X, Y]: + """Combine all dictionaries in a list together into one dictionary.""" + compressed: Dict[X,Y] = {} + for individual in list_of_dict: compressed.update(individual) + return compressed + +def collapse_dict_list_vertical(list_of_dict1: List[Dict[X, Y]], *args: List[Dict[X, Y]]) -> List[Dict[X, Y]]: + """Combine all parallel dictionaries in lists together to make a new list of dictionaries of the same length.""" + # find the length of the longest list + length = len(list_of_dict1) + for list_of_dictN in args: + length = max(length, len(list_of_dictN)) + + # create a combined list with a length the same as the longest list + collapsed = [{}] * (length) + # The reason the list_of_dict1 is not directly used to make collapsed is + # side effects can occur if all the dictionaries are not manually unioned. + + # merge contents from list_of_dict1 + for i in range(len(list_of_dict1)): + collapsed[i] = {**collapsed[i], **list_of_dict1[i]} + + # merge contents of remaining lists_of_dicts + for list_of_dictN in args: + for i in range(len(list_of_dictN)): + collapsed[i] = {**collapsed[i], **list_of_dictN[i]} + + return collapsed + +# TODO potentially these should only be created when they are directly referenced (unsure of the space/time cost of creating these initially) + +environment_vanilla_orderedstages_table = [ environment_vanilla_orderedstage_1_table, environment_vanilla_orderedstage_2_table, environment_vanilla_orderedstage_3_table, environment_vanilla_orderedstage_4_table, environment_vanilla_orderedstage_5_table ] +environment_vanilla_table = {**compress_dict_list_horizontal(environment_vanilla_orderedstages_table), **environment_vanilla_hidden_realm_table, **environment_vanilla_special_table} + +environment_sotv_orderedstages_table = [ environment_sotv_orderedstage_1_table, environment_sotv_orderedstage_2_table, environment_sotv_orderedstage_3_table, environment_sotv_orderedstage_4_table, environment_sotv_orderedstage_5_table ] +environment_sotv_non_simulacrum_table = {**compress_dict_list_horizontal(environment_sotv_orderedstages_table), **environment_sotv_special_table} +environment_sotv_table = {**environment_sotv_non_simulacrum_table} + +environment_non_orderedstages_table = {**environment_vanilla_hidden_realm_table, **environment_vanilla_special_table, **environment_sotv_simulacrum_table, **environment_sotv_special_table} +environment_orderedstages_table = collapse_dict_list_vertical(environment_vanilla_orderedstages_table, environment_sotv_orderedstages_table) +environment_ALL_table = {**environment_vanilla_table, **environment_sotv_table} + + +def shift_by_offset(dictionary: Dict[str, int], offset:int) -> Dict[str, int]: + """Shift all indexes in a dictionary by an offset""" + return {name:index+offset for name, index in dictionary.items()} diff --git a/worlds/ror2/Rules.py b/worlds/ror2/Rules.py index bf00f617..66a2e740 100644 --- a/worlds/ror2/Rules.py +++ b/worlds/ror2/Rules.py @@ -1,33 +1,154 @@ -from BaseClasses import MultiWorld +from BaseClasses import MultiWorld, CollectionState from worlds.generic.Rules import set_rule, add_rule +from .Locations import orderedstage_location +from .RoR2Environments import environment_vanilla_orderedstages_table, environment_sotv_orderedstages_table, \ + environment_orderedstages_table -def set_rules(world: MultiWorld, player: int) -> None: - total_locations = world.total_locations[player].value # total locations for current player +# Rule to see if it has access to the previous stage +def has_entrance_access_rule(multiworld: MultiWorld, stage: str, entrance: str, player: int): + multiworld.get_entrance(entrance, player).access_rule = \ + lambda state: state.has(entrance, player) and state.has(stage, player) + + +# Checks to see if chest/shrine are accessible +def has_location_access_rule(multiworld: MultiWorld, environment: str, player: int, item_number: int, item_type: str): + if item_number == 1: + multiworld.get_location(f"{environment}: {item_type} {item_number}", player).access_rule = \ + lambda state: state.has(environment, player) + if item_type == "Scavenger": + multiworld.get_location(f"{environment}: {item_type} {item_number}", player).access_rule = \ + lambda state: state.has(environment, player) and state.has("Stage_4", player) + else: + multiworld.get_location(f"{environment}: {item_type} {item_number}", player).access_rule = \ + lambda state: check_location(state, environment, player, item_number, item_type) + + +def check_location(state, environment: str, player: int, item_number: int, item_name: str): + return state.can_reach(f"{environment}: {item_name} {item_number - 1}", "Location", player) + + +# unlock event to next set of stages +def get_stage_event(multiworld: MultiWorld, player: int, stage_number: int): + if not multiworld.dlc_sotv[player]: + environment_name = multiworld.random.choices(list(environment_vanilla_orderedstages_table[stage_number].keys()), k=1) + else: + environment_name = multiworld.random.choices(list(environment_orderedstages_table[stage_number].keys()), k=1) + multiworld.get_location(f"Stage_{stage_number+1}", player).access_rule = \ + lambda state: get_one_of_the_stages(state, environment_name[0], player) + + +def get_one_of_the_stages(state: CollectionState, stage: str, player: int): + return state.has(stage, player) + + +def set_rules(multiworld: MultiWorld, player: int) -> None: + + if multiworld.goal[player] == "classic": + # classic mode + total_locations = multiworld.total_locations[player].value # total locations for current player + else: + # explore mode + total_locations = len( + orderedstage_location.get_locations( + chests=multiworld.chests_per_stage[player].value, + shrines=multiworld.shrines_per_stage[player].value, + scavengers=multiworld.scavengers_per_stage[player].value, + scanners=multiworld.scanner_per_stage[player].value, + altars=multiworld.altars_per_stage[player].value, + dlc_sotv=multiworld.dlc_sotv[player].value + ) + ) + event_location_step = 25 # set an event location at these locations for "spheres" divisions = total_locations // event_location_step - total_revivals = world.worlds[player].total_revivals # pulling this info we calculated in generate_basic + total_revivals = multiworld.worlds[player].total_revivals # pulling this info we calculated in generate_basic - if divisions: - for i in range(1, divisions): # since divisions is the floor of total_locations / 25 - event_loc = world.get_location(f"Pickup{i * event_location_step}", player) - set_rule(event_loc, - lambda state, i=i: state.can_reach(f"ItemPickup{i * event_location_step - 1}", "Location", player)) - for n in range(i * event_location_step, (i + 1) * event_location_step): # we want to create a rule for each of the 25 locations per division - if n == i * event_location_step: - set_rule(world.get_location(f"ItemPickup{n}", player), - lambda state, event_item=event_loc.item.name: state.has(event_item, player)) - else: - set_rule(world.get_location(f"ItemPickup{n}", player), - lambda state, n=n: state.can_reach(f"ItemPickup{n - 1}", "Location", player)) - for i in range(divisions * event_location_step, total_locations+1): - set_rule(world.get_location(f"ItemPickup{i}", player), - lambda state, i=i: state.can_reach(f"ItemPickup{i - 1}", "Location", player)) - set_rule(world.get_location("Victory", player), - lambda state: state.can_reach(f"ItemPickup{total_locations}", "Location", player)) - if total_revivals or world.start_with_revive[player].value: - add_rule(world.get_location("Victory", player), - lambda state: state.has("Dio's Best Friend", player, - total_revivals + world.start_with_revive[player])) + if multiworld.goal[player] == "classic": + # classic mode + if divisions: + for i in range(1, divisions): # since divisions is the floor of total_locations / 25 + event_loc = multiworld.get_location(f"Pickup{i * event_location_step}", player) + set_rule(event_loc, + lambda state, i=i: state.can_reach(f"ItemPickup{i * event_location_step - 1}", "Location", player)) + for n in range(i * event_location_step, (i + 1) * event_location_step): # we want to create a rule for each of the 25 locations per division + if n == i * event_location_step: + set_rule(multiworld.get_location(f"ItemPickup{n}", player), + lambda state, event_item=event_loc.item.name: state.has(event_item, player)) + else: + set_rule(multiworld.get_location(f"ItemPickup{n}", player), + lambda state, n=n: state.can_reach(f"ItemPickup{n - 1}", "Location", player)) + for i in range(divisions * event_location_step, total_locations+1): + set_rule(multiworld.get_location(f"ItemPickup{i}", player), + lambda state, i=i: state.can_reach(f"ItemPickup{i - 1}", "Location", player)) + set_rule(multiworld.get_location("Victory", player), + lambda state: state.can_reach(f"ItemPickup{total_locations}", "Location", player)) + if total_revivals or multiworld.start_with_revive[player].value: + add_rule(multiworld.get_location("Victory", player), + lambda state: state.has("Dio's Best Friend", player, + total_revivals + multiworld.start_with_revive[player])) - world.completion_condition[player] = lambda state: state.has("Victory", player) + elif multiworld.goal[player] == "explore": + # When explore_mode is used, + # scavengers need to be locked till after a full loop since that is when they are capable of spawning. + # (While technically the requirement is just beating 5 stages, this will ensure that the player will have + # a long enough run to have enough director credits for scavengers and + # help prevent being stuck in the same stages until that point.) + + for location in multiworld.get_locations(): + if location.player != player: continue # ignore all checks that don't belong to this player + if "Scavenger" in location.name: + add_rule(location, lambda state: state.has("Stage_5", player)) + # Regions + chests = multiworld.chests_per_stage[player] + shrines = multiworld.shrines_per_stage[player] + newts = multiworld.altars_per_stage[player] + scavengers = multiworld.scavengers_per_stage[player] + scanners = multiworld.scanner_per_stage[player] + for i in range(len(environment_vanilla_orderedstages_table)): + for environment_name, _ in environment_vanilla_orderedstages_table[i].items(): + # Make sure to go through each location + if scavengers == 1: + has_location_access_rule(multiworld, environment_name, player, scavengers, "Scavenger") + if scanners == 1: + has_location_access_rule(multiworld, environment_name, player, scanners, "Radio Scanner") + for chest in range(1, chests + 1): + has_location_access_rule(multiworld, environment_name, player, chest, "Chest") + for shrine in range(1, shrines + 1): + has_location_access_rule(multiworld, environment_name, player, shrine, "Shrine") + if newts > 0: + for newt in range(1, newts + 1): + has_location_access_rule(multiworld, environment_name, player, newt, "Newt Altar") + if i > 0: + has_entrance_access_rule(multiworld, f"Stage_{i}", environment_name, player) + get_stage_event(multiworld, player, i) + + if multiworld.dlc_sotv[player]: + for i in range(len(environment_sotv_orderedstages_table)): + for environment_name, _ in environment_sotv_orderedstages_table[i].items(): + # Make sure to go through each location + if scavengers == 1: + has_location_access_rule(multiworld, environment_name, player, scavengers, "Scavenger") + if scanners == 1: + has_location_access_rule(multiworld, environment_name, player, scanners, "Radio Scanner") + for chest in range(1, chests + 1): + has_location_access_rule(multiworld, environment_name, player, chest, "Chest") + for shrine in range(1, shrines + 1): + has_location_access_rule(multiworld, environment_name, player, shrine, "Shrine") + if newts > 0: + for newt in range(1, newts + 1): + has_location_access_rule(multiworld, environment_name, player, newt, "Newt Altar") + if i > 0: + has_entrance_access_rule(multiworld, f"Stage_{i}", environment_name, player) + has_entrance_access_rule(multiworld, f"Sky Meadow", "Hidden Realm: Bulwark's Ambry", player) + has_entrance_access_rule(multiworld, f"Hidden Realm: A Moment, Fractured", "Hidden Realm: A Moment, Whole", player) + has_entrance_access_rule(multiworld, f"Stage_1", "Hidden Realm: Gilded Coast", player) + has_entrance_access_rule(multiworld, f"Stage_1", "Hidden Realm: Bazaar Between Time", player) + has_entrance_access_rule(multiworld, f"Hidden Realm: Bazaar Between Time", "Void Fields", player) + has_entrance_access_rule(multiworld, f"Stage_5", "Commencement", player) + has_entrance_access_rule(multiworld, f"Stage_5", "Hidden Realm: A Moment, Fractured", player) + if multiworld.dlc_sotv[player]: + has_entrance_access_rule(multiworld, f"Stage_5", "Void Locus", player) + has_entrance_access_rule(multiworld, f"Void Locus", "The Planetarium", player) + # Win Condition + multiworld.completion_condition[player] = lambda state: state.has("Victory", player) diff --git a/worlds/ror2/__init__.py b/worlds/ror2/__init__.py index 127e614f..59c9801c 100644 --- a/worlds/ror2/__init__.py +++ b/worlds/ror2/__init__.py @@ -1,14 +1,14 @@ import string -from typing import Dict, List -from BaseClasses import Entrance, Item, ItemClassification, MultiWorld, Region, RegionType, Tutorial -from worlds.AutoWorld import WebWorld, World -from .Items import RiskOfRainItem, item_pool_weights, item_table -from .Locations import RiskOfRainLocation, item_pickups -from .Options import ItemWeights, ror2_options +from .Items import RiskOfRainItem, item_table, item_pool_weights, environment_offest +from .Locations import RiskOfRainLocation, get_classic_item_pickups, item_pickups, orderedstage_location from .Rules import set_rules +from .RoR2Environments import * -client_version = 1 +from BaseClasses import Region, RegionType, Entrance, Item, ItemClassification, MultiWorld, Tutorial +from .Options import ror2_options, ItemWeights +from worlds.AutoWorld import World, WebWorld +from .Regions import create_regions class RiskOfWeb(WebWorld): @@ -35,20 +35,58 @@ class RiskOfRainWorld(World): item_name_to_id = item_table location_name_to_id = item_pickups - data_version = 4 + data_version = 6 + required_client_version = (0, 3, 7) web = RiskOfWeb() total_revivals: int def generate_early(self) -> None: # figure out how many revivals should exist in the pool + if self.multiworld.goal[self.player] == "classic": + total_locations = self.multiworld.total_locations[self.player].value + else: + total_locations = len( + orderedstage_location.get_locations( + chests=self.multiworld.chests_per_stage[self.player].value, + shrines=self.multiworld.shrines_per_stage[self.player].value, + scavengers=self.multiworld.scavengers_per_stage[self.player].value, + scanners=self.multiworld.scanner_per_stage[self.player].value, + altars=self.multiworld.altars_per_stage[self.player].value, + dlc_sotv=self.multiworld.dlc_sotv[self.player].value + ) + ) self.total_revivals = int(self.multiworld.total_revivals[self.player].value / 100 * - self.multiworld.total_locations[self.player].value) - - def generate_basic(self) -> None: - # shortcut for starting_inventory... The start_with_revive option lets you start with a Dio's Best Friend + total_locations) + # self.total_revivals = self.multiworld.total_revivals[self.player].value if self.multiworld.start_with_revive[self.player].value: + self.total_revivals -= 1 + + def create_items(self) -> None: + # shortcut for starting_inventory... The start_with_revive option lets you start with a Dio's Best Friend + if self.multiworld.start_with_revive[self.player]: self.multiworld.push_precollected(self.multiworld.create_item("Dio's Best Friend", self.player)) + environments_pool = {} + # only mess with the environments if they are set as items + if self.multiworld.goal[self.player] == "explore": + + # figure out all available ordered stages for each tier + environment_available_orderedstages_table = environment_vanilla_orderedstages_table + if self.multiworld.dlc_sotv[self.player]: + environment_available_orderedstages_table = collapse_dict_list_vertical(environment_available_orderedstages_table, environment_sotv_orderedstages_table) + + environments_pool = shift_by_offset(environment_vanilla_table, environment_offest) + + if self.multiworld.dlc_sotv[self.player]: + environment_offset_table = shift_by_offset(environment_sotv_table, environment_offest) + environments_pool = {**environments_pool, **environment_offset_table} + environments_to_precollect = 5 if self.multiworld.begin_with_loop[self.player].value else 1 + # percollect environments for each stage (or just stage 1) + for i in range(environments_to_precollect): + unlock = self.multiworld.random.choices(list(environment_available_orderedstages_table[i].keys()), k=1) + self.multiworld.push_precollected(self.create_item(unlock[0])) + environments_pool.pop(unlock[0]) + # if presets are enabled generate junk_pool from the selected preset pool_option = self.multiworld.item_weights[self.player].value junk_pool: Dict[str, int] = {} @@ -70,61 +108,120 @@ class RiskOfRainWorld(World): "Legendary Item": self.multiworld.legendary_item[self.player].value, "Boss Item": self.multiworld.boss_item[self.player].value, "Lunar Item": self.multiworld.lunar_item[self.player].value, + "Void Item": self.multiworld.void_item[self.player].value, "Equipment": self.multiworld.equipment[self.player].value } # remove lunar items from the pool if they're disabled in the yaml unless lunartic is rolled - if not (self.multiworld.enable_lunar[self.player] or pool_option == ItemWeights.option_lunartic): + if not self.multiworld.enable_lunar[self.player] or pool_option == ItemWeights.option_lunartic: junk_pool.pop("Lunar Item") + # remove void items from the pool + if not self.multiworld.dlc_sotv[self.player] or pool_option == ItemWeights.option_void: + junk_pool.pop("Void Item") # Generate item pool itempool: List = [] # Add revive items for the player itempool += ["Dio's Best Friend"] * self.total_revivals + for env_name, _ in environments_pool.items(): + itempool += [env_name] + + # precollected environments are popped from the pool so counting like this is valid + nonjunk_item_count = self.total_revivals + len(environments_pool) + if self.multiworld.goal[self.player] == "classic": + # classic mode + total_locations = self.multiworld.total_locations[self.player].value + else: + # explore mode + total_locations = len( + orderedstage_location.get_locations( + chests=self.multiworld.chests_per_stage[self.player].value, + shrines=self.multiworld.shrines_per_stage[self.player].value, + scavengers=self.multiworld.scavengers_per_stage[self.player].value, + scanners=self.multiworld.scanner_per_stage[self.player].value, + altars=self.multiworld.altars_per_stage[self.player].value, + dlc_sotv=self.multiworld.dlc_sotv[self.player].value + ) + ) + junk_item_count = total_locations - nonjunk_item_count # Fill remaining items with randomly generated junk itempool += self.multiworld.random.choices(list(junk_pool.keys()), weights=list(junk_pool.values()), - k=self.multiworld.total_locations[self.player].value - self.total_revivals) + k=junk_item_count) # Convert itempool into real items itempool = list(map(lambda name: self.create_item(name), itempool)) - self.multiworld.itempool += itempool def set_rules(self) -> None: set_rules(self.multiworld, self.player) def create_regions(self) -> None: - menu = create_region(self.multiworld, self.player, "Menu") - petrichor = create_region(self.multiworld, self.player, "Petrichor V", - [f"ItemPickup{i + 1}" for i in range(self.multiworld.total_locations[self.player].value)]) - connection = Entrance(self.player, "Lobby", menu) - menu.exits.append(connection) - connection.connect(petrichor) + if self.multiworld.goal[self.player] == "classic": + # classic mode + menu = create_region(self.multiworld, self.player, "Menu") + self.multiworld.regions.append(menu) + # By using a victory region, we can define it as being connected to by several regions + # which can then determine the availability of the victory. + victory_region = create_region(self.multiworld, self.player, "Victory") + self.multiworld.regions.append(victory_region) + petrichor = create_region(self.multiworld, self.player, "Petrichor V", + get_classic_item_pickups(self.multiworld.total_locations[self.player].value)) + self.multiworld.regions.append(petrichor) - self.multiworld.regions += [menu, petrichor] + # classic mode can get to victory from the beginning of the game + to_victory = Entrance(self.player, "beating game", petrichor) + petrichor.exits.append(to_victory) + to_victory.connect(victory_region) + + connection = Entrance(self.player, "Lobby", menu) + menu.exits.append(connection) + connection.connect(petrichor) + else: + # explore mode + create_regions(self.multiworld, self.player) create_events(self.multiworld, self.player) def fill_slot_data(self): return { "itemPickupStep": self.multiworld.item_pickup_step[self.player].value, + "shrineUseStep": self.multiworld.shrine_use_step[self.player].value, + "goal": self.multiworld.goal[self.player].value, "seed": "".join(self.multiworld.per_slot_randoms[self.player].choice(string.digits) for _ in range(16)), "totalLocations": self.multiworld.total_locations[self.player].value, + "chestsPerStage": self.multiworld.chests_per_stage[self.player].value, + "shrinesPerStage": self.multiworld.shrines_per_stage[self.player].value, + "scavengersPerStage": self.multiworld.scavengers_per_stage[self.player].value, + "scannerPerStage": self.multiworld.scanner_per_stage[self.player].value, + "altarsPerStage": self.multiworld.altars_per_stage[self.player].value, "totalRevivals": self.multiworld.total_revivals[self.player].value, "startWithDio": self.multiworld.start_with_revive[self.player].value, - "FinalStageDeath": self.multiworld.final_stage_death[self.player].value + "finalStageDeath": self.multiworld.final_stage_death[self.player].value, + "deathLink": self.multiworld.death_link[self.player].value, } def create_item(self, name: str) -> Item: item_id = item_table[name] + classification = ItemClassification.filler if name == "Dio's Best Friend": classification = ItemClassification.progression - elif name in {"Equipment", "Legendary Item"}: + elif name in {"Legendary Item", "Boss Item"}: classification = ItemClassification.useful - else: - classification = ItemClassification.filler + elif name == "Lunar Item": + classification = ItemClassification.trap + + # Only check for an item to be a environment unlock if those are known to be in the pool. + # This should shave down comparions. + + elif name in environment_ALL_table.keys(): + if name in {"Void Fields", "Hidden Realm: Bazaar Between Time", "Hidden Realm: Bulwark's Ambry", + "Hidden Realm: Gilded Coast,"}: + classification = ItemClassification.useful + else: + classification = ItemClassification.progression + item = RiskOfRainItem(name, classification, item_id, self.player) return item @@ -135,23 +232,31 @@ def create_events(world: MultiWorld, player: int) -> None: if total_locations / 25 == num_of_events: num_of_events -= 1 world_region = world.get_region("Petrichor V", player) + if world.goal[player] == "classic": + # only setup Pickups when using classic_mode + for i in range(num_of_events): + event_loc = RiskOfRainLocation(player, f"Pickup{(i + 1) * 25}", None, world_region) + event_loc.place_locked_item(RiskOfRainItem(f"Pickup{(i + 1) * 25}", ItemClassification.progression, None, player)) + event_loc.access_rule = \ + lambda state, i=i: state.can_reach(f"ItemPickup{((i + 1) * 25) - 1}", "Location", player) + world_region.locations.append(event_loc) + elif world.goal[player] == "explore": + for n in range(1, 6): - for i in range(num_of_events): - event_loc = RiskOfRainLocation(player, f"Pickup{(i + 1) * 25}", None, world_region) - event_loc.place_locked_item(RiskOfRainItem(f"Pickup{(i + 1) * 25}", ItemClassification.progression, None, player)) - event_loc.access_rule(lambda state, i=i: state.can_reach(f"ItemPickup{((i + 1) * 25) - 1}", player)) - world_region.locations.append(event_loc) + event_region = world.get_region(f"OrderedStage_{n}", player) + event_loc = RiskOfRainLocation(player, f"Stage_{n}", None, event_region) + event_loc.place_locked_item(RiskOfRainItem(f"Stage_{n}", ItemClassification.progression, None, player)) + event_loc.show_in_spoiler = False + event_region.locations.append(event_loc) - victory_event = RiskOfRainLocation(player, "Victory", None, world_region) + victory_region = world.get_region("Victory", player) + victory_event = RiskOfRainLocation(player, "Victory", None, victory_region) victory_event.place_locked_item(RiskOfRainItem("Victory", ItemClassification.progression, None, player)) world_region.locations.append(victory_event) -def create_region(world: MultiWorld, player: int, name: str, locations: List[str] = None) -> Region: +def create_region(world: MultiWorld, player: int, name: str, locations: Dict[str, int] = {}) -> Region: ret = Region(name, RegionType.Generic, name, player, world) - if locations: - for location in locations: - loc_id = item_pickups[location] - location = RiskOfRainLocation(player, location, loc_id, ret) - ret.locations.append(location) + for location_name, location_id in locations.items(): + ret.locations.append(RiskOfRainLocation(player, location_name, location_id, ret)) return ret diff --git a/worlds/ror2/docs/en_Risk of Rain 2.md b/worlds/ror2/docs/en_Risk of Rain 2.md index a58269a3..f2f15fac 100644 --- a/worlds/ror2/docs/en_Risk of Rain 2.md +++ b/worlds/ror2/docs/en_Risk of Rain 2.md @@ -12,19 +12,34 @@ functionality in which certain chests (made clear via a location check progress multiworld. The items that _would have been_ in those chests will be returned to the Risk of Rain player via grants by other players in other worlds. +There are two modes in risk of rain. Classic Mode and Explore Mode + +Classic Mode: + + - Classic mode implements pure multiworld +functionality in which certain chests (made clear via a location check progress bar) will send an item out to the +multiworld. The items that _would have been_ in those chests will be returned to the Risk of Rain player via grants by +other players in other worlds. + +Explore Mode: + + - Just like in Classic mode chests will send out an item to the multiworld. The difference is that each environment + will have a set amount that can be sent out and shrines along with other things that will need to be checked. + Also, each environment is an item and, you'll need it to be able to access it. + ## What is the goal of Risk of Rain 2 in Archipelago? -Just like in the original game, any way to "beat the game or obliterate" counts as a win. By default, if you die while -on a final boss stage, that also counts as a win. (You can turn this off in your player settings.) **You do not need to -complete all the location checks** to win; any item you don't collect is automatically sent out to the multiworld when -you meet your goal. +Just like in the original game, any way to "beat the game or obliterate" counts as a win. There is a setting that +if you die while on a final boss stage, that also counts as a win.(You can turn this on in your player settings.) +**You do not need to complete all the location checks** to win; any item you don't collect may be released if the +server options allow. If you die before you accomplish your goal, you can start a new run. You will start the run with any items that you received from other players. Any items that you picked up the "normal" way will be lost. Note, you can play Simulacrum mode as part of an Archipelago, but you can't achieve any of the victory conditions in -Simulacrum. So you could, for example, collect most of your items through a Simulacrum run, then finish a normal mode -run while keeping the items you received via the multiworld. +Simulacrum. So you could, for example, collect most of your items through a Simulacrum run(only works in classic mode), +then finish a normal mode run while keeping the items you received via the multiworld. ## Can you play multiplayer? @@ -38,6 +53,8 @@ settings apply, so each Risk of Rain 2 player slot in the multiworld needs to be for example, have two players trade off hosting and making progress on each other's player slot, but a single co-op instance can't make progress towards multiple player slots in the multiworld. +Explore mode is untested in multiplayer and will likely not work until a later release. + ## What Risk of Rain items can appear in other players' worlds? The Risk of Rain items are: @@ -49,6 +66,7 @@ The Risk of Rain items are: * `Lunar Item` (Blue items) * `Equipment` (Orange items) * `Dio's Best Friend` (Used if you set the YAML setting `total_revives_available` above `0`) +* `Void Item` (Purple items) (needs dlc_sotv: enabled) Each item grants you a random in-game item from the category it belongs to. @@ -57,6 +75,29 @@ in-game item of that tier will appear in the Risk of Rain player's inventory. If the player already has an equipment item equipped then the _item that was equipped_ will be dropped on the ground and _ the new equipment_ will take it's place. (If you want the old one back, pick it up.) +Explore Mode items are: + +* `Titanic Plains (1)`, `Titanic Plains (2)`, `Distant Roost (1)`, `Distant Roost (2)` +* `Abandoned Aqueduct`, `Wetland Aspect` +* `Rallypoint Delta`, `Scorched Acres` +* `Abyssal Depths`, `Siren's Call`, `Sundered Grove` +* `Sky Meadow` +* `Commencement` +* `All the Hidden Realms` + +Dlc_Sotv items +* `Siphoned Forest` +* `Aphelian Sanctuary` +* `Sulfur Pools` +* `Void Locus` + +When a explore item is granted it will unlock that environment and will now be accessible to progress to victory! The +game will still pick randomly which environment is next but it will first check to see if they are available. If you have +them unlocked it will weight the game to have a ***higher chance*** to go to one you have checks versus one you have +already completed. You will still not be able to goto a stage 3 environment from a stage 1 environment. + + + ### How many items are there? Since a Risk of Rain 2 run can go on indefinitely, you have to configure how many collectible items (also known as @@ -65,6 +106,10 @@ to 250** items. The number of items will be randomized between all players, so y item pickup step based on how many items the other players in the multiworld have. (Around 100 seems to be a good ballpark if you want to have a similar number of items to most other games.) +In explore mode the amount of checks base on how many **chests, shrines, scavengers, radio scanners and, newt altars** +are in the pool. With just the base game the numbers are **52 to 516** and with the dlc its **60 to 660** with +everything on default being **216** + After you have completed the specified number of checks, you won't send anything else to the multiworld. You can receive up to the specified number of randomized items from the multiworld as the players find them. In either case, you can continue to collect items as normal in Risk of Rain 2 if you've already found all your location checks.