From 5f9ce2b7b60fe05a54ccd3498c67da7cac361a63 Mon Sep 17 00:00:00 2001 From: Scipio Wright Date: Fri, 19 Jan 2024 15:31:45 -0500 Subject: [PATCH] Noita: Update to use new Options API (#2370) Reworking the options to make it work with the new options API. Also reworked stuff in several spots to use world: NoitaWorld instead of multiworld: MultiWorld --- worlds/noita/Events.py | 42 ----- worlds/noita/Rules.py | 166 ------------------- worlds/noita/__init__.py | 31 ++-- worlds/noita/events.py | 43 +++++ worlds/noita/{Items.py => items.py} | 44 ++--- worlds/noita/{Locations.py => locations.py} | 23 +-- worlds/noita/{Options.py => options.py} | 30 ++-- worlds/noita/{Regions.py => regions.py} | 64 ++++---- worlds/noita/rules.py | 172 ++++++++++++++++++++ 9 files changed, 314 insertions(+), 301 deletions(-) delete mode 100644 worlds/noita/Events.py delete mode 100644 worlds/noita/Rules.py create mode 100644 worlds/noita/events.py rename worlds/noita/{Items.py => items.py} (82%) rename worlds/noita/{Locations.py => locations.py} (94%) rename worlds/noita/{Options.py => options.py} (87%) rename worlds/noita/{Regions.py => regions.py} (61%) create mode 100644 worlds/noita/rules.py diff --git a/worlds/noita/Events.py b/worlds/noita/Events.py deleted file mode 100644 index e759d38c6c7a..000000000000 --- a/worlds/noita/Events.py +++ /dev/null @@ -1,42 +0,0 @@ -from typing import Dict - -from BaseClasses import Item, ItemClassification, Location, MultiWorld, Region -from . import Items, Locations - - -def create_event(player: int, name: str) -> Item: - return Items.NoitaItem(name, ItemClassification.progression, None, player) - - -def create_location(player: int, name: str, region: Region) -> Location: - return Locations.NoitaLocation(player, name, None, region) - - -def create_locked_location_event(multiworld: MultiWorld, player: int, region_name: str, item: str) -> Location: - region = multiworld.get_region(region_name, player) - - new_location = create_location(player, item, region) - new_location.place_locked_item(create_event(player, item)) - - region.locations.append(new_location) - return new_location - - -def create_all_events(multiworld: MultiWorld, player: int) -> None: - for region, event in event_locks.items(): - create_locked_location_event(multiworld, player, region, event) - - multiworld.completion_condition[player] = lambda state: state.has("Victory", player) - - -# Maps region names to event names -event_locks: Dict[str, str] = { - "The Work": "Victory", - "Mines": "Portal to Holy Mountain 1", - "Coal Pits": "Portal to Holy Mountain 2", - "Snowy Depths": "Portal to Holy Mountain 3", - "Hiisi Base": "Portal to Holy Mountain 4", - "Underground Jungle": "Portal to Holy Mountain 5", - "The Vault": "Portal to Holy Mountain 6", - "Temple of the Art": "Portal to Holy Mountain 7", -} diff --git a/worlds/noita/Rules.py b/worlds/noita/Rules.py deleted file mode 100644 index 8190b80dc710..000000000000 --- a/worlds/noita/Rules.py +++ /dev/null @@ -1,166 +0,0 @@ -from typing import List, NamedTuple, Set - -from BaseClasses import CollectionState, MultiWorld -from . import Items, Locations -from .Options import BossesAsChecks, VictoryCondition -from worlds.generic import Rules as GenericRules - - -class EntranceLock(NamedTuple): - source: str - destination: str - event: str - items_needed: int - - -entrance_locks: List[EntranceLock] = [ - EntranceLock("Mines", "Coal Pits Holy Mountain", "Portal to Holy Mountain 1", 1), - EntranceLock("Coal Pits", "Snowy Depths Holy Mountain", "Portal to Holy Mountain 2", 2), - EntranceLock("Snowy Depths", "Hiisi Base Holy Mountain", "Portal to Holy Mountain 3", 3), - EntranceLock("Hiisi Base", "Underground Jungle Holy Mountain", "Portal to Holy Mountain 4", 4), - EntranceLock("Underground Jungle", "Vault Holy Mountain", "Portal to Holy Mountain 5", 5), - EntranceLock("The Vault", "Temple of the Art Holy Mountain", "Portal to Holy Mountain 6", 6), - EntranceLock("Temple of the Art", "Laboratory Holy Mountain", "Portal to Holy Mountain 7", 7), -] - - -holy_mountain_regions: List[str] = [ - "Coal Pits Holy Mountain", - "Snowy Depths Holy Mountain", - "Hiisi Base Holy Mountain", - "Underground Jungle Holy Mountain", - "Vault Holy Mountain", - "Temple of the Art Holy Mountain", - "Laboratory Holy Mountain", -] - - -wand_tiers: List[str] = [ - "Wand (Tier 1)", # Coal Pits - "Wand (Tier 2)", # Snowy Depths - "Wand (Tier 3)", # Hiisi Base - "Wand (Tier 4)", # Underground Jungle - "Wand (Tier 5)", # The Vault - "Wand (Tier 6)", # Temple of the Art -] - -items_hidden_from_shops: List[str] = ["Gold (200)", "Gold (1000)", "Potion", "Random Potion", "Secret Potion", - "Chaos Die", "Greed Die", "Kammi", "Refreshing Gourd", "Sädekivi", "Broken Wand", - "Powder Pouch"] - -perk_list: List[str] = list(filter(Items.item_is_perk, Items.item_table.keys())) - - -# ---------------- -# Helper Functions -# ---------------- - - -def has_perk_count(state: CollectionState, player: int, amount: int) -> bool: - return sum(state.count(perk, player) for perk in perk_list) >= amount - - -def has_orb_count(state: CollectionState, player: int, amount: int) -> bool: - return state.count("Orb", player) >= amount - - -def forbid_items_at_location(multiworld: MultiWorld, location_name: str, items: Set[str], player: int): - location = multiworld.get_location(location_name, player) - GenericRules.forbid_items_for_player(location, items, player) - - -# ---------------- -# Rule Functions -# ---------------- - - -# Prevent gold and potions from appearing as purchasable items in shops (because physics will destroy them) -def ban_items_from_shops(multiworld: MultiWorld, player: int) -> None: - for location_name in Locations.location_name_to_id.keys(): - if "Shop Item" in location_name: - forbid_items_at_location(multiworld, location_name, items_hidden_from_shops, player) - - -# Prevent high tier wands from appearing in early Holy Mountain shops -def ban_early_high_tier_wands(multiworld: MultiWorld, player: int) -> None: - for i, region_name in enumerate(holy_mountain_regions): - wands_to_forbid = wand_tiers[i+1:] - - locations_in_region = Locations.location_region_mapping[region_name].keys() - for location_name in locations_in_region: - forbid_items_at_location(multiworld, location_name, wands_to_forbid, player) - - # Prevent high tier wands from appearing in the Secret shop - wands_to_forbid = wand_tiers[3:] - locations_in_region = Locations.location_region_mapping["Secret Shop"].keys() - for location_name in locations_in_region: - forbid_items_at_location(multiworld, location_name, wands_to_forbid, player) - - -def lock_holy_mountains_into_spheres(multiworld: MultiWorld, player: int) -> None: - for lock in entrance_locks: - location = multiworld.get_entrance(f"From {lock.source} To {lock.destination}", player) - GenericRules.set_rule(location, lambda state, evt=lock.event: state.has(evt, player)) - - -def holy_mountain_unlock_conditions(multiworld: MultiWorld, player: int) -> None: - victory_condition = multiworld.victory_condition[player].value - for lock in entrance_locks: - location = multiworld.get_location(lock.event, player) - - if victory_condition == VictoryCondition.option_greed_ending: - location.access_rule = lambda state, items_needed=lock.items_needed: ( - has_perk_count(state, player, items_needed//2) - ) - elif victory_condition == VictoryCondition.option_pure_ending: - location.access_rule = lambda state, items_needed=lock.items_needed: ( - has_perk_count(state, player, items_needed//2) and - has_orb_count(state, player, items_needed) - ) - elif victory_condition == VictoryCondition.option_peaceful_ending: - location.access_rule = lambda state, items_needed=lock.items_needed: ( - has_perk_count(state, player, items_needed//2) and - has_orb_count(state, player, items_needed * 3) - ) - - -def biome_unlock_conditions(multiworld: MultiWorld, player: int): - lukki_entrances = multiworld.get_region("Lukki Lair", player).entrances - magical_entrances = multiworld.get_region("Magical Temple", player).entrances - wizard_entrances = multiworld.get_region("Wizards' Den", player).entrances - for entrance in lukki_entrances: - entrance.access_rule = lambda state: state.has("Melee Immunity Perk", player) and\ - state.has("All-Seeing Eye Perk", player) - for entrance in magical_entrances: - entrance.access_rule = lambda state: state.has("All-Seeing Eye Perk", player) - for entrance in wizard_entrances: - entrance.access_rule = lambda state: state.has("All-Seeing Eye Perk", player) - - -def victory_unlock_conditions(multiworld: MultiWorld, player: int) -> None: - victory_condition = multiworld.victory_condition[player].value - victory_location = multiworld.get_location("Victory", player) - - if victory_condition == VictoryCondition.option_pure_ending: - victory_location.access_rule = lambda state: has_orb_count(state, player, 11) - elif victory_condition == VictoryCondition.option_peaceful_ending: - victory_location.access_rule = lambda state: has_orb_count(state, player, 33) - - -# ---------------- -# Main Function -# ---------------- - - -def create_all_rules(multiworld: MultiWorld, player: int) -> None: - if multiworld.players > 1: - ban_items_from_shops(multiworld, player) - ban_early_high_tier_wands(multiworld, player) - lock_holy_mountains_into_spheres(multiworld, player) - holy_mountain_unlock_conditions(multiworld, player) - biome_unlock_conditions(multiworld, player) - victory_unlock_conditions(multiworld, player) - - # Prevent the Map perk (used to find Toveri) from being on Toveri (boss) - if multiworld.bosses_as_checks[player].value >= BossesAsChecks.option_all_bosses: - forbid_items_at_location(multiworld, "Toveri", {"Spatial Awareness Perk"}, player) diff --git a/worlds/noita/__init__.py b/worlds/noita/__init__.py index 792b90e3f551..b8f8e4ae8346 100644 --- a/worlds/noita/__init__.py +++ b/worlds/noita/__init__.py @@ -1,6 +1,8 @@ from BaseClasses import Item, Tutorial from worlds.AutoWorld import WebWorld, World -from . import Events, Items, Locations, Options, Regions, Rules +from typing import Dict, Any +from . import events, items, locations, regions, rules +from .options import NoitaOptions class NoitaWeb(WebWorld): @@ -24,13 +26,14 @@ class NoitaWorld(World): """ game = "Noita" - option_definitions = Options.noita_options + options: NoitaOptions + options_dataclass = NoitaOptions - item_name_to_id = Items.item_name_to_id - location_name_to_id = Locations.location_name_to_id + item_name_to_id = items.item_name_to_id + location_name_to_id = locations.location_name_to_id - item_name_groups = Items.item_name_groups - location_name_groups = Locations.location_name_groups + item_name_groups = items.item_name_groups + location_name_groups = locations.location_name_groups data_version = 2 web = NoitaWeb() @@ -40,21 +43,21 @@ def generate_early(self): raise Exception("Noita yaml's slot name has invalid character(s).") # Returned items will be sent over to the client - def fill_slot_data(self): - return {name: getattr(self.multiworld, name)[self.player].value for name in self.option_definitions} + def fill_slot_data(self) -> Dict[str, Any]: + return self.options.as_dict("death_link", "victory_condition", "path_option", "hidden_chests", + "pedestal_checks", "orbs_as_checks", "bosses_as_checks", "extra_orbs", "shop_price") def create_regions(self) -> None: - Regions.create_all_regions_and_connections(self.multiworld, self.player) - Events.create_all_events(self.multiworld, self.player) + regions.create_all_regions_and_connections(self) def create_item(self, name: str) -> Item: - return Items.create_item(self.player, name) + return items.create_item(self.player, name) def create_items(self) -> None: - Items.create_all_items(self.multiworld, self.player) + items.create_all_items(self) def set_rules(self) -> None: - Rules.create_all_rules(self.multiworld, self.player) + rules.create_all_rules(self) def get_filler_item_name(self) -> str: - return self.multiworld.random.choice(Items.filler_items) + return self.random.choice(items.filler_items) diff --git a/worlds/noita/events.py b/worlds/noita/events.py new file mode 100644 index 000000000000..4ec04e98b457 --- /dev/null +++ b/worlds/noita/events.py @@ -0,0 +1,43 @@ +from typing import Dict, TYPE_CHECKING +from BaseClasses import Item, ItemClassification, Location, Region +from . import items, locations + +if TYPE_CHECKING: + from . import NoitaWorld + + +def create_event(player: int, name: str) -> Item: + return items.NoitaItem(name, ItemClassification.progression, None, player) + + +def create_location(player: int, name: str, region: Region) -> Location: + return locations.NoitaLocation(player, name, None, region) + + +def create_locked_location_event(player: int, region: Region, item: str) -> Location: + new_location = create_location(player, item, region) + new_location.place_locked_item(create_event(player, item)) + + region.locations.append(new_location) + return new_location + + +def create_all_events(world: "NoitaWorld", created_regions: Dict[str, Region]) -> None: + for region_name, event in event_locks.items(): + region = created_regions[region_name] + create_locked_location_event(world.player, region, event) + + world.multiworld.completion_condition[world.player] = lambda state: state.has("Victory", world.player) + + +# Maps region names to event names +event_locks: Dict[str, str] = { + "The Work": "Victory", + "Mines": "Portal to Holy Mountain 1", + "Coal Pits": "Portal to Holy Mountain 2", + "Snowy Depths": "Portal to Holy Mountain 3", + "Hiisi Base": "Portal to Holy Mountain 4", + "Underground Jungle": "Portal to Holy Mountain 5", + "The Vault": "Portal to Holy Mountain 6", + "Temple of the Art": "Portal to Holy Mountain 7", +} diff --git a/worlds/noita/Items.py b/worlds/noita/items.py similarity index 82% rename from worlds/noita/Items.py rename to worlds/noita/items.py index c859a8039494..6b662fbee692 100644 --- a/worlds/noita/Items.py +++ b/worlds/noita/items.py @@ -1,9 +1,14 @@ import itertools from collections import Counter -from typing import Dict, List, NamedTuple, Set +from typing import Dict, List, NamedTuple, Set, TYPE_CHECKING -from BaseClasses import Item, ItemClassification, MultiWorld -from .Options import BossesAsChecks, VictoryCondition, ExtraOrbs +from BaseClasses import Item, ItemClassification +from .options import BossesAsChecks, VictoryCondition, ExtraOrbs + +if TYPE_CHECKING: + from . import NoitaWorld +else: + NoitaWorld = object class ItemData(NamedTuple): @@ -44,39 +49,40 @@ def create_kantele(victory_condition: VictoryCondition) -> List[str]: return ["Kantele"] if victory_condition.value >= VictoryCondition.option_pure_ending else [] -def create_random_items(multiworld: MultiWorld, player: int, weights: Dict[str, int], count: int) -> List[str]: +def create_random_items(world: NoitaWorld, weights: Dict[str, int], count: int) -> List[str]: filler_pool = weights.copy() - if multiworld.bad_effects[player].value == 0: + if not world.options.bad_effects: del filler_pool["Trap"] - return multiworld.random.choices(population=list(filler_pool.keys()), - weights=list(filler_pool.values()), - k=count) + return world.random.choices(population=list(filler_pool.keys()), + weights=list(filler_pool.values()), + k=count) -def create_all_items(multiworld: MultiWorld, player: int) -> None: - locations_to_fill = len(multiworld.get_unfilled_locations(player)) +def create_all_items(world: NoitaWorld) -> None: + player = world.player + locations_to_fill = len(world.multiworld.get_unfilled_locations(player)) itempool = ( create_fixed_item_pool() - + create_orb_items(multiworld.victory_condition[player], multiworld.extra_orbs[player]) - + create_spatial_awareness_item(multiworld.bosses_as_checks[player]) - + create_kantele(multiworld.victory_condition[player]) + + create_orb_items(world.options.victory_condition, world.options.extra_orbs) + + create_spatial_awareness_item(world.options.bosses_as_checks) + + create_kantele(world.options.victory_condition) ) # if there's not enough shop-allowed items in the pool, we can encounter gen issues # 39 is the number of shop-valid items we need to guarantee if len(itempool) < 39: - itempool += create_random_items(multiworld, player, shop_only_filler_weights, 39 - len(itempool)) + itempool += create_random_items(world, shop_only_filler_weights, 39 - len(itempool)) # this is so that it passes tests and gens if you have minimal locations and only one player - if multiworld.players == 1: - for location in multiworld.get_unfilled_locations(player): + if world.multiworld.players == 1: + for location in world.multiworld.get_unfilled_locations(player): if "Shop Item" in location.name: location.item = create_item(player, itempool.pop()) - locations_to_fill = len(multiworld.get_unfilled_locations(player)) + locations_to_fill = len(world.multiworld.get_unfilled_locations(player)) - itempool += create_random_items(multiworld, player, filler_weights, locations_to_fill - len(itempool)) - multiworld.itempool += [create_item(player, name) for name in itempool] + itempool += create_random_items(world, filler_weights, locations_to_fill - len(itempool)) + world.multiworld.itempool += [create_item(player, name) for name in itempool] # 110000 - 110032 diff --git a/worlds/noita/Locations.py b/worlds/noita/locations.py similarity index 94% rename from worlds/noita/Locations.py rename to worlds/noita/locations.py index 7c27d699ccba..afe16c54e4b2 100644 --- a/worlds/noita/Locations.py +++ b/worlds/noita/locations.py @@ -201,11 +201,10 @@ class LocationFlag(IntEnum): } -# Iterating the hidden chest and pedestal locations here to avoid clutter above -def generate_location_entries(locname: str, locinfo: LocationData) -> Dict[str, int]: - if locinfo.ltype in ["chest", "pedestal"]: - return {f"{locname} {i + 1}": locinfo.id + i for i in range(20)} - return {locname: locinfo.id} +def make_location_range(location_name: str, base_id: int, amt: int) -> Dict[str, int]: + if amt == 1: + return {location_name: base_id} + return {f"{location_name} {i+1}": base_id + i for i in range(amt)} location_name_groups: Dict[str, Set[str]] = {"shop": set(), "orb": set(), "boss": set(), "chest": set(), @@ -215,9 +214,11 @@ def generate_location_entries(locname: str, locinfo: LocationData) -> Dict[str, for location_group in location_region_mapping.values(): for locname, locinfo in location_group.items(): - location_name_to_id.update(generate_location_entries(locname, locinfo)) - if locinfo.ltype in ["chest", "pedestal"]: - for i in range(20): - location_name_groups[locinfo.ltype].add(f"{locname} {i + 1}") - else: - location_name_groups[locinfo.ltype].add(locname) + # Iterating the hidden chest and pedestal locations here to avoid clutter above + amount = 20 if locinfo.ltype in ["chest", "pedestal"] else 1 + entries = make_location_range(locname, locinfo.id, amount) + + location_name_to_id.update(entries) + location_name_groups[locinfo.ltype].update(entries.keys()) + +shop_locations = {name for name in location_name_to_id.keys() if "Shop Item" in name} diff --git a/worlds/noita/Options.py b/worlds/noita/options.py similarity index 87% rename from worlds/noita/Options.py rename to worlds/noita/options.py index 0b54597f364d..7d987571a589 100644 --- a/worlds/noita/Options.py +++ b/worlds/noita/options.py @@ -1,5 +1,5 @@ -from typing import Dict -from Options import AssembleOptions, Choice, DeathLink, DefaultOnToggle, Range, StartInventoryPool +from Options import Choice, DeathLink, DefaultOnToggle, Range, StartInventoryPool, PerGameCommonOptions +from dataclasses import dataclass class PathOption(Choice): @@ -99,16 +99,16 @@ class ShopPrice(Choice): default = 100 -noita_options: Dict[str, AssembleOptions] = { - "start_inventory_from_pool": StartInventoryPool, - "death_link": DeathLink, - "bad_effects": Traps, - "victory_condition": VictoryCondition, - "path_option": PathOption, - "hidden_chests": HiddenChests, - "pedestal_checks": PedestalChecks, - "orbs_as_checks": OrbsAsChecks, - "bosses_as_checks": BossesAsChecks, - "extra_orbs": ExtraOrbs, - "shop_price": ShopPrice, -} +@dataclass +class NoitaOptions(PerGameCommonOptions): + start_inventory_from_pool: StartInventoryPool + death_link: DeathLink + bad_effects: Traps + victory_condition: VictoryCondition + path_option: PathOption + hidden_chests: HiddenChests + pedestal_checks: PedestalChecks + orbs_as_checks: OrbsAsChecks + bosses_as_checks: BossesAsChecks + extra_orbs: ExtraOrbs + shop_price: ShopPrice diff --git a/worlds/noita/Regions.py b/worlds/noita/regions.py similarity index 61% rename from worlds/noita/Regions.py rename to worlds/noita/regions.py index 561d483b4865..6a9c86772381 100644 --- a/worlds/noita/Regions.py +++ b/worlds/noita/regions.py @@ -1,48 +1,43 @@ # Regions are areas in your game that you travel to. -from typing import Dict, Set, List +from typing import Dict, List, TYPE_CHECKING -from BaseClasses import Entrance, MultiWorld, Region -from . import Locations +from BaseClasses import Entrance, Region +from . import locations +from .events import create_all_events +if TYPE_CHECKING: + from . import NoitaWorld -def add_location(player: int, loc_name: str, id: int, region: Region) -> None: - location = Locations.NoitaLocation(player, loc_name, id, region) - region.locations.append(location) - -def add_locations(multiworld: MultiWorld, player: int, region: Region) -> None: - locations = Locations.location_region_mapping.get(region.name, {}) - for location_name, location_data in locations.items(): +def create_locations(world: "NoitaWorld", region: Region) -> None: + locs = locations.location_region_mapping.get(region.name, {}) + for location_name, location_data in locs.items(): location_type = location_data.ltype flag = location_data.flag - opt_orbs = multiworld.orbs_as_checks[player].value - opt_bosses = multiworld.bosses_as_checks[player].value - opt_paths = multiworld.path_option[player].value - opt_num_chests = multiworld.hidden_chests[player].value - opt_num_pedestals = multiworld.pedestal_checks[player].value + is_orb_allowed = location_type == "orb" and flag <= world.options.orbs_as_checks + is_boss_allowed = location_type == "boss" and flag <= world.options.bosses_as_checks + amount = 0 + if flag == locations.LocationFlag.none or is_orb_allowed or is_boss_allowed: + amount = 1 + elif location_type == "chest" and flag <= world.options.path_option: + amount = world.options.hidden_chests.value + elif location_type == "pedestal" and flag <= world.options.path_option: + amount = world.options.pedestal_checks.value - is_orb_allowed = location_type == "orb" and flag <= opt_orbs - is_boss_allowed = location_type == "boss" and flag <= opt_bosses - if flag == Locations.LocationFlag.none or is_orb_allowed or is_boss_allowed: - add_location(player, location_name, location_data.id, region) - elif location_type == "chest" and flag <= opt_paths: - for i in range(opt_num_chests): - add_location(player, f"{location_name} {i+1}", location_data.id + i, region) - elif location_type == "pedestal" and flag <= opt_paths: - for i in range(opt_num_pedestals): - add_location(player, f"{location_name} {i+1}", location_data.id + i, region) + region.add_locations(locations.make_location_range(location_name, location_data.id, amount), + locations.NoitaLocation) # Creates a new Region with the locations found in `location_region_mapping` and adds them to the world. -def create_region(multiworld: MultiWorld, player: int, region_name: str) -> Region: - new_region = Region(region_name, player, multiworld) - add_locations(multiworld, player, new_region) +def create_region(world: "NoitaWorld", region_name: str) -> Region: + new_region = Region(region_name, world.player, world.multiworld) + create_locations(world, new_region) return new_region -def create_regions(multiworld: MultiWorld, player: int) -> Dict[str, Region]: - return {name: create_region(multiworld, player, name) for name in noita_regions} +def create_regions(world: "NoitaWorld") -> Dict[str, Region]: + return {name: create_region(world, name) for name in noita_regions} # An "Entrance" is really just a connection between two regions @@ -60,11 +55,12 @@ def create_connections(player: int, regions: Dict[str, Region]) -> None: # Creates all regions and connections. Called from NoitaWorld. -def create_all_regions_and_connections(multiworld: MultiWorld, player: int) -> None: - created_regions = create_regions(multiworld, player) - create_connections(player, created_regions) +def create_all_regions_and_connections(world: "NoitaWorld") -> None: + created_regions = create_regions(world) + create_connections(world.player, created_regions) + create_all_events(world, created_regions) - multiworld.regions += created_regions.values() + world.multiworld.regions += created_regions.values() # Oh, what a tangled web we weave diff --git a/worlds/noita/rules.py b/worlds/noita/rules.py new file mode 100644 index 000000000000..95039bee4635 --- /dev/null +++ b/worlds/noita/rules.py @@ -0,0 +1,172 @@ +from typing import List, NamedTuple, Set, TYPE_CHECKING + +from BaseClasses import CollectionState +from . import items, locations +from .options import BossesAsChecks, VictoryCondition +from worlds.generic import Rules as GenericRules + +if TYPE_CHECKING: + from . import NoitaWorld + + +class EntranceLock(NamedTuple): + source: str + destination: str + event: str + items_needed: int + + +entrance_locks: List[EntranceLock] = [ + EntranceLock("Mines", "Coal Pits Holy Mountain", "Portal to Holy Mountain 1", 1), + EntranceLock("Coal Pits", "Snowy Depths Holy Mountain", "Portal to Holy Mountain 2", 2), + EntranceLock("Snowy Depths", "Hiisi Base Holy Mountain", "Portal to Holy Mountain 3", 3), + EntranceLock("Hiisi Base", "Underground Jungle Holy Mountain", "Portal to Holy Mountain 4", 4), + EntranceLock("Underground Jungle", "Vault Holy Mountain", "Portal to Holy Mountain 5", 5), + EntranceLock("The Vault", "Temple of the Art Holy Mountain", "Portal to Holy Mountain 6", 6), + EntranceLock("Temple of the Art", "Laboratory Holy Mountain", "Portal to Holy Mountain 7", 7), +] + + +holy_mountain_regions: List[str] = [ + "Coal Pits Holy Mountain", + "Snowy Depths Holy Mountain", + "Hiisi Base Holy Mountain", + "Underground Jungle Holy Mountain", + "Vault Holy Mountain", + "Temple of the Art Holy Mountain", + "Laboratory Holy Mountain", +] + + +wand_tiers: List[str] = [ + "Wand (Tier 1)", # Coal Pits + "Wand (Tier 2)", # Snowy Depths + "Wand (Tier 3)", # Hiisi Base + "Wand (Tier 4)", # Underground Jungle + "Wand (Tier 5)", # The Vault + "Wand (Tier 6)", # Temple of the Art +] + + +items_hidden_from_shops: Set[str] = {"Gold (200)", "Gold (1000)", "Potion", "Random Potion", "Secret Potion", + "Chaos Die", "Greed Die", "Kammi", "Refreshing Gourd", "Sädekivi", "Broken Wand", + "Powder Pouch"} + +perk_list: List[str] = list(filter(items.item_is_perk, items.item_table.keys())) + + +# ---------------- +# Helper Functions +# ---------------- + + +def has_perk_count(state: CollectionState, player: int, amount: int) -> bool: + return sum(state.count(perk, player) for perk in perk_list) >= amount + + +def has_orb_count(state: CollectionState, player: int, amount: int) -> bool: + return state.count("Orb", player) >= amount + + +def forbid_items_at_locations(world: "NoitaWorld", shop_locations: Set[str], forbidden_items: Set[str]): + for shop_location in shop_locations: + location = world.multiworld.get_location(shop_location, world.player) + GenericRules.forbid_items_for_player(location, forbidden_items, world.player) + + +# ---------------- +# Rule Functions +# ---------------- + + +# Prevent gold and potions from appearing as purchasable items in shops (because physics will destroy them) +# def ban_items_from_shops(world: "NoitaWorld") -> None: +# for location_name in Locations.location_name_to_id.keys(): +# if "Shop Item" in location_name: +# forbid_items_at_location(world, location_name, items_hidden_from_shops) +def ban_items_from_shops(world: "NoitaWorld") -> None: + forbid_items_at_locations(world, locations.shop_locations, items_hidden_from_shops) + + +# Prevent high tier wands from appearing in early Holy Mountain shops +def ban_early_high_tier_wands(world: "NoitaWorld") -> None: + for i, region_name in enumerate(holy_mountain_regions): + wands_to_forbid = set(wand_tiers[i+1:]) + + locations_in_region = set(locations.location_region_mapping[region_name].keys()) + forbid_items_at_locations(world, locations_in_region, wands_to_forbid) + + # Prevent high tier wands from appearing in the Secret shop + wands_to_forbid = set(wand_tiers[3:]) + locations_in_region = set(locations.location_region_mapping["Secret Shop"].keys()) + forbid_items_at_locations(world, locations_in_region, wands_to_forbid) + + +def lock_holy_mountains_into_spheres(world: "NoitaWorld") -> None: + for lock in entrance_locks: + location = world.multiworld.get_entrance(f"From {lock.source} To {lock.destination}", world.player) + GenericRules.set_rule(location, lambda state, evt=lock.event: state.has(evt, world.player)) + + +def holy_mountain_unlock_conditions(world: "NoitaWorld") -> None: + victory_condition = world.options.victory_condition.value + for lock in entrance_locks: + location = world.multiworld.get_location(lock.event, world.player) + + if victory_condition == VictoryCondition.option_greed_ending: + location.access_rule = lambda state, items_needed=lock.items_needed: ( + has_perk_count(state, world.player, items_needed//2) + ) + elif victory_condition == VictoryCondition.option_pure_ending: + location.access_rule = lambda state, items_needed=lock.items_needed: ( + has_perk_count(state, world.player, items_needed//2) and + has_orb_count(state, world.player, items_needed) + ) + elif victory_condition == VictoryCondition.option_peaceful_ending: + location.access_rule = lambda state, items_needed=lock.items_needed: ( + has_perk_count(state, world.player, items_needed//2) and + has_orb_count(state, world.player, items_needed * 3) + ) + + +def biome_unlock_conditions(world: "NoitaWorld"): + lukki_entrances = world.multiworld.get_region("Lukki Lair", world.player).entrances + magical_entrances = world.multiworld.get_region("Magical Temple", world.player).entrances + wizard_entrances = world.multiworld.get_region("Wizards' Den", world.player).entrances + for entrance in lukki_entrances: + entrance.access_rule = lambda state: state.has("Melee Immunity Perk", world.player) and\ + state.has("All-Seeing Eye Perk", world.player) + for entrance in magical_entrances: + entrance.access_rule = lambda state: state.has("All-Seeing Eye Perk", world.player) + for entrance in wizard_entrances: + entrance.access_rule = lambda state: state.has("All-Seeing Eye Perk", world.player) + + +def victory_unlock_conditions(world: "NoitaWorld") -> None: + victory_condition = world.options.victory_condition.value + victory_location = world.multiworld.get_location("Victory", world.player) + + if victory_condition == VictoryCondition.option_pure_ending: + victory_location.access_rule = lambda state: has_orb_count(state, world.player, 11) + elif victory_condition == VictoryCondition.option_peaceful_ending: + victory_location.access_rule = lambda state: has_orb_count(state, world.player, 33) + + +# ---------------- +# Main Function +# ---------------- + + +def create_all_rules(world: "NoitaWorld") -> None: + if world.multiworld.players > 1: + ban_items_from_shops(world) + ban_early_high_tier_wands(world) + lock_holy_mountains_into_spheres(world) + holy_mountain_unlock_conditions(world) + biome_unlock_conditions(world) + victory_unlock_conditions(world) + + # Prevent the Map perk (used to find Toveri) from being on Toveri (boss) + if world.options.bosses_as_checks.value >= BossesAsChecks.option_all_bosses: + toveri = world.multiworld.get_location("Toveri", world.player) + GenericRules.forbid_items_for_player(toveri, {"Spatial Awareness Perk"}, world.player)