diff --git a/README.md b/README.md index e5419072a955..654cd6d6000f 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ Currently, the following games are supported: * Clique * Adventure * DLC Quest +* Noita For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/). Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled diff --git a/worlds/noita/Events.py b/worlds/noita/Events.py new file mode 100644 index 000000000000..e759d38c6c7a --- /dev/null +++ b/worlds/noita/Events.py @@ -0,0 +1,42 @@ +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/Items.py b/worlds/noita/Items.py new file mode 100644 index 000000000000..6499e945175b --- /dev/null +++ b/worlds/noita/Items.py @@ -0,0 +1,156 @@ +import itertools +from collections import Counter +from typing import Dict, List, NamedTuple, Optional, Set + +from BaseClasses import Item, ItemClassification, MultiWorld +from .Options import BossesAsChecks, VictoryCondition + + +class ItemData(NamedTuple): + code: Optional[int] + group: str + classification: ItemClassification = ItemClassification.progression + required_num: int = 0 + + +class NoitaItem(Item): + game: str = "Noita" + + +def create_item(player: int, name: str) -> Item: + item_data = item_table[name] + return NoitaItem(name, item_data.classification, item_data.code, player) + + +def create_fixed_item_pool() -> List[str]: + required_items: Dict[str, int] = {name: data.required_num for name, data in item_table.items()} + return list(Counter(required_items).elements()) + + +def create_orb_items(victory_condition: VictoryCondition) -> List[str]: + orb_count = 0 + if victory_condition == VictoryCondition.option_pure_ending: + orb_count = 11 + elif victory_condition == VictoryCondition.option_peaceful_ending: + orb_count = 33 + return ["Orb" for _ in range(orb_count)] + + +def create_spatial_awareness_item(bosses_as_checks: BossesAsChecks) -> List[str]: + return ["Spatial Awareness Perk"] if bosses_as_checks.value >= BossesAsChecks.option_all_bosses else [] + + +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, random_count: int) -> List[str]: + filler_pool = filler_weights.copy() + if multiworld.bad_effects[player].value == 0: + del filler_pool["Trap"] + + return multiworld.random.choices( + population=list(filler_pool.keys()), + weights=list(filler_pool.values()), + k=random_count + ) + + +def create_all_items(multiworld: MultiWorld, player: int) -> None: + sum_locations = len(multiworld.get_unfilled_locations(player)) + + itempool = ( + create_fixed_item_pool() + + create_orb_items(multiworld.victory_condition[player]) + + create_spatial_awareness_item(multiworld.bosses_as_checks[player]) + + create_kantele(multiworld.victory_condition[player]) + ) + + random_count = sum_locations - len(itempool) + itempool += create_random_items(multiworld, player, random_count) + + multiworld.itempool += [create_item(player, name) for name in itempool] + + +# 110000 - 110032 +item_table: Dict[str, ItemData] = { + "Trap": ItemData(110000, "Traps", ItemClassification.trap), + "Extra Max HP": ItemData(110001, "Pickups", ItemClassification.useful), + "Spell Refresher": ItemData(110002, "Pickups", ItemClassification.filler), + "Potion": ItemData(110003, "Items", ItemClassification.filler), + "Gold (200)": ItemData(110004, "Gold", ItemClassification.filler), + "Gold (1000)": ItemData(110005, "Gold", ItemClassification.filler), + "Wand (Tier 1)": ItemData(110006, "Wands", ItemClassification.useful), + "Wand (Tier 2)": ItemData(110007, "Wands", ItemClassification.useful), + "Wand (Tier 3)": ItemData(110008, "Wands", ItemClassification.useful), + "Wand (Tier 4)": ItemData(110009, "Wands", ItemClassification.useful), + "Wand (Tier 5)": ItemData(110010, "Wands", ItemClassification.useful), + "Wand (Tier 6)": ItemData(110011, "Wands", ItemClassification.useful), + "Kantele": ItemData(110012, "Wands", ItemClassification.useful), + "Fire Immunity Perk": ItemData(110013, "Perks", ItemClassification.progression, 1), + "Toxic Immunity Perk": ItemData(110014, "Perks", ItemClassification.progression, 1), + "Explosion Immunity Perk": ItemData(110015, "Perks", ItemClassification.progression, 1), + "Melee Immunity Perk": ItemData(110016, "Perks", ItemClassification.progression, 1), + "Electricity Immunity Perk": ItemData(110017, "Perks", ItemClassification.progression, 1), + "Tinker with Wands Everywhere Perk": ItemData(110018, "Perks", ItemClassification.progression, 1), + "All-Seeing Eye Perk": ItemData(110019, "Perks", ItemClassification.progression, 1), + "Spatial Awareness Perk": ItemData(110020, "Perks", ItemClassification.progression), + "Extra Life Perk": ItemData(110021, "Repeatable Perks", ItemClassification.useful), + "Orb": ItemData(110022, "Orbs", ItemClassification.progression_skip_balancing), + "Random Potion": ItemData(110023, "Items", ItemClassification.filler), + "Secret Potion": ItemData(110024, "Items", ItemClassification.filler), + "Powder Pouch": ItemData(110025, "Items", ItemClassification.filler), + "Chaos Die": ItemData(110026, "Items", ItemClassification.filler), + "Greed Die": ItemData(110027, "Items", ItemClassification.filler), + "Kammi": ItemData(110028, "Items", ItemClassification.filler), + "Refreshing Gourd": ItemData(110029, "Items", ItemClassification.filler), + "Sädekivi": ItemData(110030, "Items", ItemClassification.filler), + "Broken Wand": ItemData(110031, "Items", ItemClassification.filler), + +} + +filler_weights: Dict[str, int] = { + "Trap": 15, + "Extra Max HP": 25, + "Spell Refresher": 20, + "Potion": 40, + "Gold (200)": 15, + "Gold (1000)": 6, + "Wand (Tier 1)": 10, + "Wand (Tier 2)": 8, + "Wand (Tier 3)": 7, + "Wand (Tier 4)": 6, + "Wand (Tier 5)": 5, + "Wand (Tier 6)": 4, + "Extra Life Perk": 10, + "Random Potion": 9, + "Secret Potion": 10, + "Powder Pouch": 10, + "Chaos Die": 4, + "Greed Die": 4, + "Kammi": 4, + "Refreshing Gourd": 4, + "Sädekivi": 3, + "Broken Wand": 10, +} + + +# These helper functions make the comprehensions below more readable +def get_item_group(item_name: str) -> str: + return item_table[item_name].group + + +def item_is_filler(item_name: str) -> bool: + return item_table[item_name].classification == ItemClassification.filler + + +def item_is_perk(item_name: str) -> bool: + return item_table[item_name].group == "Perks" + + +filler_items: List[str] = list(filter(item_is_filler, item_table.keys())) +item_name_to_id: Dict[str, int] = {name: data.code for name, data in item_table.items()} + +item_name_groups: Dict[str, Set[str]] = { + group: set(item_names) for group, item_names in itertools.groupby(item_table, get_item_group) +} diff --git a/worlds/noita/Locations.py b/worlds/noita/Locations.py new file mode 100644 index 000000000000..bbfae1c94341 --- /dev/null +++ b/worlds/noita/Locations.py @@ -0,0 +1,214 @@ +# Locations are specific points that you would obtain an item at. +from enum import IntEnum +from typing import Dict, NamedTuple, Optional + +from BaseClasses import Location + + +class NoitaLocation(Location): + game: str = "Noita" + + +class LocationData(NamedTuple): + id: int + flag: int = 0 + ltype: Optional[str] = "" + + +class LocationFlag(IntEnum): + none = 0 + main_path = 1 + side_path = 2 + main_world = 3 + parallel_worlds = 4 + + +# Mapping of items in each region. +# Only the first Hidden Chest and Pedestal are mapped here, the others are created in Regions. +# ltype key: "chest" = Hidden Chests, "pedestal" = Pedestals, "boss" = Boss, "orb" = Orb. +# 110000-110649 +location_region_mapping: Dict[str, Dict[str, LocationData]] = { + "Coal Pits Holy Mountain": { + "Coal Pits Holy Mountain Shop Item 1": LocationData(110000), + "Coal Pits Holy Mountain Shop Item 2": LocationData(110001), + "Coal Pits Holy Mountain Shop Item 3": LocationData(110002), + "Coal Pits Holy Mountain Shop Item 4": LocationData(110003), + "Coal Pits Holy Mountain Shop Item 5": LocationData(110004), + "Coal Pits Holy Mountain Spell Refresh": LocationData(110005), + }, + "Snowy Depths Holy Mountain": { + "Snowy Depths Holy Mountain Shop Item 1": LocationData(110006), + "Snowy Depths Holy Mountain Shop Item 2": LocationData(110007), + "Snowy Depths Holy Mountain Shop Item 3": LocationData(110008), + "Snowy Depths Holy Mountain Shop Item 4": LocationData(110009), + "Snowy Depths Holy Mountain Shop Item 5": LocationData(110010), + "Snowy Depths Holy Mountain Spell Refresh": LocationData(110011), + }, + "Hiisi Base Holy Mountain": { + "Hiisi Base Holy Mountain Shop Item 1": LocationData(110012), + "Hiisi Base Holy Mountain Shop Item 2": LocationData(110013), + "Hiisi Base Holy Mountain Shop Item 3": LocationData(110014), + "Hiisi Base Holy Mountain Shop Item 4": LocationData(110015), + "Hiisi Base Holy Mountain Shop Item 5": LocationData(110016), + "Hiisi Base Holy Mountain Spell Refresh": LocationData(110017), + }, + "Underground Jungle Holy Mountain": { + "Underground Jungle Holy Mountain Shop Item 1": LocationData(110018), + "Underground Jungle Holy Mountain Shop Item 2": LocationData(110019), + "Underground Jungle Holy Mountain Shop Item 3": LocationData(110020), + "Underground Jungle Holy Mountain Shop Item 4": LocationData(110021), + "Underground Jungle Holy Mountain Shop Item 5": LocationData(110022), + "Underground Jungle Holy Mountain Spell Refresh": LocationData(110023), + }, + "Vault Holy Mountain": { + "Vault Holy Mountain Shop Item 1": LocationData(110024), + "Vault Holy Mountain Shop Item 2": LocationData(110025), + "Vault Holy Mountain Shop Item 3": LocationData(110026), + "Vault Holy Mountain Shop Item 4": LocationData(110027), + "Vault Holy Mountain Shop Item 5": LocationData(110028), + "Vault Holy Mountain Spell Refresh": LocationData(110029), + }, + "Temple of the Art Holy Mountain": { + "Temple of the Art Holy Mountain Shop Item 1": LocationData(110030), + "Temple of the Art Holy Mountain Shop Item 2": LocationData(110031), + "Temple of the Art Holy Mountain Shop Item 3": LocationData(110032), + "Temple of the Art Holy Mountain Shop Item 4": LocationData(110033), + "Temple of the Art Holy Mountain Shop Item 5": LocationData(110034), + "Temple of the Art Holy Mountain Spell Refresh": LocationData(110035), + }, + "Laboratory Holy Mountain": { + "Laboratory Holy Mountain Shop Item 1": LocationData(110036), + "Laboratory Holy Mountain Shop Item 2": LocationData(110037), + "Laboratory Holy Mountain Shop Item 3": LocationData(110038), + "Laboratory Holy Mountain Shop Item 4": LocationData(110039), + "Laboratory Holy Mountain Shop Item 5": LocationData(110040), + "Laboratory Holy Mountain Spell Refresh": LocationData(110041), + }, + "Secret Shop": { + "Secret Shop Item 1": LocationData(110042), + "Secret Shop Item 2": LocationData(110043), + "Secret Shop Item 3": LocationData(110044), + "Secret Shop Item 4": LocationData(110045), + }, + "Floating Island": { + "Floating Island Orb": LocationData(110658, LocationFlag.main_path, "orb"), + }, + "Pyramid": { + "Kolmisilmän Koipi": LocationData(110649, LocationFlag.main_world, "boss"), + "Pyramid Orb": LocationData(110659, LocationFlag.main_world, "orb"), + "Sandcave Orb": LocationData(110662, LocationFlag.main_world, "orb"), + }, + "Overgrown Cavern": { + "Overgrown Cavern Chest": LocationData(110526, LocationFlag.main_world, "chest"), + "Overgrown Cavern Pedestal": LocationData(110546, LocationFlag.main_world, "pedestal"), + }, + "Lake": { + "Syväolento": LocationData(110651, LocationFlag.main_world, "boss"), + }, + "Frozen Vault": { + "Frozen Vault Orb": LocationData(110660, LocationFlag.main_world, "orb"), + "Frozen Vault Chest": LocationData(110566, LocationFlag.main_world, "chest"), + "Frozen Vault Pedestal": LocationData(110586, LocationFlag.main_world, "pedestal"), + }, + "Mines": { + "Mines Chest": LocationData(110046, LocationFlag.main_path, "chest"), + "Mines Pedestal": LocationData(110066, LocationFlag.main_path, "pedestal"), + }, + # Collapsed Mines is a very small area, combining it with the Mines. Leaving this here in case we change our minds. + # "Collapsed Mines": { + # "Collapsed Mines Chest": LocationData(110086, LocationFlag.main_path, "chest"), + # "Collapsed Mines Pedestal": LocationData(110106, LocationFlag.main_path, "pedestal"), + # }, + "Ancient Laboratory": { + "Ylialkemisti": LocationData(110656, LocationFlag.side_path, "boss"), + }, + "Abyss Orb Room": { + "Sauvojen Tuntija": LocationData(110650, LocationFlag.side_path, "boss"), + "Abyss Orb": LocationData(110665, LocationFlag.main_path, "orb"), + }, + "Below Lava Lake": { + "Lava Lake Orb": LocationData(110661, LocationFlag.side_path, "orb"), + }, + "Coal Pits": { + "Coal Pits Chest": LocationData(110126, LocationFlag.main_path, "chest"), + "Coal Pits Pedestal": LocationData(110146, LocationFlag.main_path, "pedestal"), + }, + "Fungal Caverns": { + "Fungal Caverns Chest": LocationData(110166, LocationFlag.side_path, "chest"), + "Fungal Caverns Pedestal": LocationData(110186, LocationFlag.side_path, "pedestal"), + }, + "Snowy Depths": { + "Snowy Depths Chest": LocationData(110206, LocationFlag.main_path, "chest"), + "Snowy Depths Pedestal": LocationData(110226, LocationFlag.main_path, "pedestal"), + }, + "Magical Temple": { + "Magical Temple Orb": LocationData(110663, LocationFlag.side_path, "orb"), + }, + "Hiisi Base": { + "Hiisi Base Chest": LocationData(110246, LocationFlag.main_path, "chest"), + "Hiisi Base Pedestal": LocationData(110266, LocationFlag.main_path, "pedestal"), + }, + "Underground Jungle": { + "Suomuhauki": LocationData(110648, LocationFlag.main_path, "boss"), + "Underground Jungle Chest": LocationData(110286, LocationFlag.main_path, "chest"), + "Underground Jungle Pedestal": LocationData(110306, LocationFlag.main_path, "pedestal"), + }, + "Lukki Lair": { + "Lukki Lair Orb": LocationData(110664, LocationFlag.side_path, "orb"), + "Lukki Lair Chest": LocationData(110326, LocationFlag.side_path, "chest"), + "Lukki Lair Pedestal": LocationData(110346, LocationFlag.side_path, "pedestal"), + }, + "The Vault": { + "The Vault Chest": LocationData(110366, LocationFlag.main_path, "chest"), + "The Vault Pedestal": LocationData(110386, LocationFlag.main_path, "pedestal"), + }, + "Temple of the Art": { + "Gate Guardian": LocationData(110652, LocationFlag.main_path, "boss"), + "Temple of the Art Chest": LocationData(110406, LocationFlag.main_path, "chest"), + "Temple of the Art Pedestal": LocationData(110426, LocationFlag.main_path, "pedestal"), + }, + "The Tower": { + "The Tower Chest": LocationData(110606, LocationFlag.main_world, "chest"), + "The Tower Pedestal": LocationData(110626, LocationFlag.main_world, "pedestal"), + }, + "Wizard's Den": { + "Mestarien Mestari": LocationData(110655, LocationFlag.main_world, "boss"), + "Wizard's Den Orb": LocationData(110668, LocationFlag.main_world, "orb"), + "Wizards' Den Chest": LocationData(110446, LocationFlag.main_world, "chest"), + "Wizards' Den Pedestal": LocationData(110466, LocationFlag.main_world, "pedestal"), + }, + "Powerplant": { + "Kolmisilmän silmä": LocationData(110657, LocationFlag.main_world, "boss"), + "Power Plant Chest": LocationData(110486, LocationFlag.main_world, "chest"), + "Power Plant Pedestal": LocationData(110506, LocationFlag.main_world, "pedestal"), + }, + "Snow Chasm": { + "Unohdettu": LocationData(110653, LocationFlag.main_world, "boss"), + "Snow Chasm Orb": LocationData(110667, LocationFlag.main_world, "orb"), + }, + "Deep Underground": { + "Limatoukka": LocationData(110647, LocationFlag.main_world, "boss"), + }, + "The Laboratory": { + "Kolmisilmä": LocationData(110646, LocationFlag.main_path, "boss"), + }, + "Friend Cave": { + "Toveri": LocationData(110654, LocationFlag.main_world, "boss"), + }, + "The Work (Hell)": { + "The Work (Hell) Orb": LocationData(110666, LocationFlag.main_world, "orb"), + }, +} + + +# 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} + + +location_name_to_id: Dict[str, int] = {} +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)) diff --git a/worlds/noita/Options.py b/worlds/noita/Options.py new file mode 100644 index 000000000000..270814f41abc --- /dev/null +++ b/worlds/noita/Options.py @@ -0,0 +1,89 @@ +from typing import Dict +from Options import Choice, DeathLink, DefaultOnToggle, Option, Range + + +class PathOption(Choice): + """Choose where you would like Hidden Chest and Pedestal checks to be placed. + Main Path includes the main 7 biomes you typically go through to get to the final boss. + Side Path includes the Lukki Lair and Fungal Caverns. 9 biomes total. + Main World includes the full world (excluding parallel worlds). 14 biomes total. + Note: The Collapsed Mines have been combined into the Mines as the biome is tiny.""" + display_name = "Path Option" + option_main_path = 1 + option_side_path = 2 + option_main_world = 3 + default = 1 + + +class HiddenChests(Range): + """Number of hidden chest checks added to the applicable biomes.""" + display_name = "Hidden Chests per Biome" + range_start = 0 + range_end = 20 + default = 3 + + +class PedestalChecks(Range): + """Number of checks that will spawn on pedestals in the applicable biomes.""" + display_name = "Pedestal Checks per Biome" + range_start = 0 + range_end = 20 + default = 6 + + +class Traps(DefaultOnToggle): + """Whether negative effects on the Noita world are added to the item pool.""" + display_name = "Traps" + + +class OrbsAsChecks(Choice): + """Decides whether finding the orbs that naturally spawn in the world count as checks. + The Main Path option includes only the Floating Island and Abyss Orb Room orbs. + The Side Path option includes the Main Path, Magical Temple, Lukki Lair, and Lava Lake orbs. + The Main World option includes all 11 orbs.""" + display_name = "Orbs as Location Checks" + option_no_orbs = 0 + option_main_path = 1 + option_side_path = 2 + option_main_world = 3 + default = 0 + + +class BossesAsChecks(Choice): + """Makes bosses count as location checks. The boss only needs to die, you do not need the kill credit. + The Main Path option includes Gate Guardian, Suomuhauki, and Kolmisilmä. + The Side Path option includes the Main Path bosses, Sauvojen Tuntija, and Ylialkemisti. + The All Bosses option includes all 12 bosses.""" + display_name = "Bosses as Location Checks" + option_no_bosses = 0 + option_main_path = 1 + option_side_path = 2 + option_all_bosses = 3 + default = 0 + + +# Note: the Sampo is an item that is picked up to trigger the boss fight at the normal ending location. +# The sampo is required for every ending (having orbs and bringing the sampo to a different spot changes the ending). +class VictoryCondition(Choice): + """Greed is to get to the bottom, beat the boss, and win the game. + Pure is to get the 11 orbs in the main world, grab the sampo, and bring it to the mountain altar. + Peaceful is to get all 33 orbs in main + parallel, grab the sampo, and bring it to the mountain altar. + Orbs will be added to the randomizer pool according to what victory condition you chose. + The base game orbs will not count towards these victory conditions.""" + display_name = "Victory Condition" + option_greed_ending = 0 + option_pure_ending = 1 + option_peaceful_ending = 2 + default = 0 + + +noita_options: Dict[str, type(Option)] = { + "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, +} diff --git a/worlds/noita/Regions.py b/worlds/noita/Regions.py new file mode 100644 index 000000000000..c6dac74d1362 --- /dev/null +++ b/worlds/noita/Regions.py @@ -0,0 +1,145 @@ +# Regions are areas in your game that you travel to. +from typing import Dict, Set + +from BaseClasses import Entrance, MultiWorld, Region +from . import Locations + + +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(): + 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 <= 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) + + +# 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) + 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} + + +# An "Entrance" is really just a connection between two regions +def create_entrance(player: int, source: str, destination: str, regions: Dict[str, Region]): + entrance = Entrance(player, f"From {source} To {destination}", regions[source]) + entrance.connect(regions[destination]) + return entrance + + +# Creates connections based on our access mapping in `noita_connections`. +def create_connections(player: int, regions: Dict[str, Region]) -> None: + for source, destinations in noita_connections.items(): + new_entrances = [create_entrance(player, source, destination, regions) for destination in destinations] + regions[source].exits = new_entrances + + +# 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) + + multiworld.regions += created_regions.values() + + +# Oh, what a tangled web we weave +# Notes to create artificial spheres: +# - Shaft is excluded to disconnect Mines from the Snowy Depths +# - Lukki Lair is disconnected from The Vault +# - Overgrown Cavern is connected to the Underground Jungle instead of the Desert due to similar difficulty +# - Powerplant is disconnected from the Sandcave due to difficulty and sphere creation +# - Snow Chasm is disconnected from the Snowy Wasteland +# - Pyramid is connected to the Hiisi Base instead of the Desert due to similar difficulty +# - Frozen Vault is connected to the Vault instead of the Snowy Wasteland due to similar difficulty +noita_connections: Dict[str, Set[str]] = { + "Menu": {"Forest"}, + "Forest": {"Mines", "Floating Island", "Desert", "Snowy Wasteland"}, + "Snowy Wasteland": {"Lake", "Forest"}, + "Frozen Vault": {"The Vault"}, + "Lake": {"Snowy Wasteland", "Desert"}, + "Desert": {"Lake", "Forest"}, + "Floating Island": {"Forest"}, + "Pyramid": {"Hiisi Base"}, + "Overgrown Cavern": {"Sandcave", "Undeground Jungle"}, + "Sandcave": {"Overgrown Cavern"}, + + ### + "Mines": {"Collapsed Mines", "Coal Pits Holy Mountain", "Lava Lake", "Forest"}, + "Collapsed Mines": {"Mines", "Dark Cave"}, + "Lava Lake": {"Mines", "Abyss Orb Room", "Below Lava Lake"}, + "Abyss Orb Room": {"Lava Lake"}, + "Below Lava Lake": {"Lava Lake"}, + "Dark Cave": {"Ancient Laboratory", "Collapsed Mines"}, + "Ancient Laboratory": {"Dark Cave"}, + + ### + "Coal Pits Holy Mountain": {"Coal Pits"}, + "Coal Pits": {"Coal Pits Holy Mountain", "Fungal Caverns", "Snowy Depths Holy Mountain"}, + "Fungal Caverns": {"Coal Pits"}, + + ### + "Snowy Depths Holy Mountain": {"Snowy Depths"}, + "Snowy Depths": {"Snowy Depths Holy Mountain", "Hiisi Base Holy Mountain", "Magical Temple"}, + "Magical Temple": {"Snowy Depths"}, + + ### + "Hiisi Base Holy Mountain": {"Hiisi Base"}, + "Hiisi Base": {"Hiisi Base Holy Mountain", "Secret Shop", "Pyramid", "Underground Jungle Holy Mountain"}, + "Secret Shop": {"Hiisi Base"}, + + ### + "Underground Jungle Holy Mountain": {"Underground Jungle"}, + "Underground Jungle": {"Underground Jungle Holy Mountain", "Dragoncave", "Overgrown Cavern", "Vault Holy Mountain", + "Lukki Lair"}, + "Dragoncave": {"Underground Jungle"}, + "Lukki Lair": {"Underground Jungle", "Snow Chasm", "Frozen Vault"}, + "Snow Chasm": {}, + + ### + "Vault Holy Mountain": {"The Vault"}, + "The Vault": {"Vault Holy Mountain", "Frozen Vault", "Temple of the Art Holy Mountain"}, + + ### + "Temple of the Art Holy Mountain": {"Temple of the Art"}, + "Temple of the Art": {"Temple of the Art Holy Mountain", "Laboratory Holy Mountain", "The Tower", + "Wizard's Den"}, + "Wizard's Den": {"Temple of the Art", "Powerplant"}, + "Powerplant": {"Wizard's Den", "Deep Underground"}, + "The Tower": {"Forest"}, + "Deep Underground": {}, + + ### + "Laboratory Holy Mountain": {"The Laboratory"}, + "The Laboratory": {"Laboratory Holy Mountain", "The Work", "Friend Cave", "The Work (Hell)"}, + "Friend Cave": {}, + "The Work": {}, + "The Work (Hell)": {}, + ### +} + +noita_regions: Set[str] = set(noita_connections.keys()).union(*noita_connections.values()) diff --git a/worlds/noita/Rules.py b/worlds/noita/Rules.py new file mode 100644 index 000000000000..e0e4b16baa67 --- /dev/null +++ b/worlds/noita/Rules.py @@ -0,0 +1,153 @@ +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: 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.item_count(perk, player) for perk in perk_list) >= amount + + +def has_orb_count(state: CollectionState, player: int, amount: int) -> bool: + return state.item_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 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: + 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) + 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 new file mode 100644 index 000000000000..253c8e9df706 --- /dev/null +++ b/worlds/noita/__init__.py @@ -0,0 +1,55 @@ +from BaseClasses import Item, Tutorial +from worlds.AutoWorld import WebWorld, World +from . import Events, Items, Locations, Options, Regions, Rules + + +class NoitaWeb(WebWorld): + tutorials = [Tutorial( + "Multiworld Setup Guide", + "A guide to setting up the Noita integration for Archipelago multiworld games.", + "English", + "setup_en.md", + "setup/en", + ["Heinermann", "ScipioWright", "DaftBrit"] + )] + theme = "partyTime" + bug_report_page = "https://github.com/DaftBrit/NoitaArchipelago/issues" + + +# Keeping World slim so that it's easier to comprehend +class NoitaWorld(World): + """ + Noita is a magical action roguelite set in a world where every pixel is physically simulated. Fight, explore, melt, + burn, freeze, and evaporate your way through the procedurally generated world using wands you've created yourself. + """ + + game = "Noita" + option_definitions = Options.noita_options + + 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 + data_version = 1 + + web = NoitaWeb() + + # 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 create_regions(self) -> None: + Regions.create_all_regions_and_connections(self.multiworld, self.player) + Events.create_all_events(self.multiworld, self.player) + + def create_item(self, name: str) -> Item: + return Items.create_item(self.player, name) + + def create_items(self) -> None: + Items.create_all_items(self.multiworld, self.player) + + def set_rules(self) -> None: + Rules.create_all_rules(self.multiworld, self.player) + + def get_filler_item_name(self) -> str: + return self.multiworld.random.choice(Items.filler_items) diff --git a/worlds/noita/docs/en_Noita.md b/worlds/noita/docs/en_Noita.md new file mode 100644 index 000000000000..ac587917183a --- /dev/null +++ b/worlds/noita/docs/en_Noita.md @@ -0,0 +1,63 @@ +# Noita + +## Where is the settings page? + +The [player settings page for this game](../player-settings) contains all the options you need to configure and export a +config file. + +## What does randomization do to this game? + +Noita is a procedurally generated action roguelite. During runs in Noita you will find potions, wands, spells, perks, +pickups, and chests. Shop items, chests/hearts hidden in the environment, and pedestal items will be replaced with +location checks. Orbs and boss drops will optionally give location checks as well, if they are enabled in the settings. +Noita items that can be found in other players' games include specific perks, orbs (optional), wands, +hearts (Extra Max Health), gold, potions, and other items. If traps are enabled, some randomized negative effects can +affect your game when found. + +## What is the goal of Noita? + +The vanilla goal of Noita is to progress through each level and beat the final boss, taking the Sampo +(gear shaped object) through the portal, and interacting with the altar at the end. There are other endings as well +which require you to gather a certain number of orbs and bring the sampo to an alternate altar. +The Archipelago implementation maintains the same goals. While creating your YAML, you will choose what your goal will +be. While the sampo's location is not randomized, orbs are added to the randomizer pool based on the number of orbs +required for your goal. + +Starting a fresh run after death will re-deliver *some* previously delivered items. The standard wand, potion, and perk +pool are unaffected by the multiworld item pools. This will not present an issue with progression, and will make +progression easier as the multiworld progresses. + +## What Noita items can appear in other players' worlds? + +Positive rewards can be: + +* `Gold (200 or 1000)` +* `Extra Max HP` +* `Spell Refresher` +* `Random Wand (Tier 1 - 6)` +* `Potion` +* `Orb` +* `Immunity Perk` +* `Extra Life` +* `Other Helpful Perks` +* `Miscellaneous Other Items` + +Traps consist of all "Bad" and "Awful" events from Noita's native stream integration. Examples include: + +* `Slow Player` +* `Trailing Lava` +* `Worm Rain` +* `Spawning black holes` + +### How many items are there? + +The number of items is dependent on the settings you choose. Please check the information boxes next to the settings +when setting up your YAML for more information. + +## What does another world's item look like in Noita? + +Other players' items will look like the Archipelago logo. + +## Is Archipelago compatible with other Noita mods? + +Yes, most other Noita mods *should* work. However, they have not been tested. diff --git a/worlds/noita/docs/setup_en.md b/worlds/noita/docs/setup_en.md new file mode 100644 index 000000000000..29e151b5c017 --- /dev/null +++ b/worlds/noita/docs/setup_en.md @@ -0,0 +1,44 @@ +# Noita Setup Guide + +## Installation + +### Game + +Go through the standard installation process for [Noita](https://noitagame.com/) on any of its supported platforms. + +### Install Archipelago Mod + +Download the Archipelago mod zip from the GitHub page: + +[Archipelago Mod Download](https://github.com/DaftBrit/NoitaArchipelago/releases/latest) + +Firstly, go to your Noita installation directory. + +* **On Steam:** Find **Noita** in your Steam library. Right click, select *Manage* → *Browse local files*. +* **On GOG Galaxy:** Find **Noita** in your Installed Games library. Right click, select *Manage installation* → +*Show folder*. + +Here you should see your game files and a folder called `mods`. Create a folder called `archipelago` and place all files +from within the zip folder directly into the `archipelago` folder. After starting Noita, select the *Mods* menu. Here +you should see the *Archipelago* mod listed. + +In order to enable the mod you will first need to toggle **Unsafe mods** from *Disabled* to *Allowed*. This is required, +as some external libraries are used by the mod in order to communicate with the Archipelago server. Once that is done, +you can now enable the *Archipelago* mod (it should have an `[x]` next to it). + +### Configure Archipelago Mod + +In the Options menu, select Mod Settings. Under the Archipelago drop down, you will see the options for *Server*, +*Port*, and *Slot*, where you can fill in the relevant information. + +Once you start a new run in Noita, you should see "Connected to Archipelago server" in the bottom left of the screen. If +you do not see this message, ensure that the mod is enabled and installed per the instructions above. + +## Configuring your YAML File +### What is a YAML and why do I need one? +You can see the [basic multiworld setup guide](/tutorial/Archipelago/setup/en) here on the Archipelago website to learn +about why Archipelago uses YAML files and what they're for. + +### Where do I get a YAML? +You can use the [game settings page for Noita](/games/Noita/player-settings) here on the Archipelago website to +generate a YAML using a graphical interface.