Noita: implement new game (#1676)

* Noita: implement new game (#1676)

---------

Co-authored-by: DaftBrit <87314354+DaftBrit@users.noreply.github.com>
Co-authored-by: l.kelsall@b4rn.org.uk <l.kelsall@b4rn.org.uk>
Co-authored-by: Fabian Dill <Berserker66@users.noreply.github.com>
Co-authored-by: Scipio Wright <scipiowright@gmail.com>
Co-authored-by: Scipio Wright <lightdemonjoe4@gmail.com>
Co-authored-by: Zach Parks <zach@alliware.com>
This commit is contained in:
Adam Heinermann 2023-04-19 20:21:56 -07:00 committed by GitHub
parent 722757e18a
commit 4dc934729d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 962 additions and 0 deletions

View File

@ -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

42
worlds/noita/Events.py Normal file
View File

@ -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",
}

156
worlds/noita/Items.py Normal file
View File

@ -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)
}

214
worlds/noita/Locations.py Normal file
View File

@ -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))

89
worlds/noita/Options.py Normal file
View File

@ -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,
}

145
worlds/noita/Regions.py Normal file
View File

@ -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())

153
worlds/noita/Rules.py Normal file
View File

@ -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)

55
worlds/noita/__init__.py Normal file
View File

@ -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)

View File

@ -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.

View File

@ -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.