Noita: Modernization Refactor (#4980)

This commit is contained in:
Scipio Wright
2025-05-14 07:55:45 -04:00
committed by GitHub
parent 02fd75c018
commit 2a0d0b4224
6 changed files with 59 additions and 87 deletions

View File

@@ -38,7 +38,7 @@ class NoitaWorld(World):
web = NoitaWeb() web = NoitaWeb()
def generate_early(self) -> None: def generate_early(self) -> None:
if not self.multiworld.get_player_name(self.player).isascii(): if not self.player_name.isascii():
raise Exception("Noita yaml's slot name has invalid character(s).") raise Exception("Noita yaml's slot name has invalid character(s).")
# Returned items will be sent over to the client # Returned items will be sent over to the client

View File

@@ -1,4 +1,4 @@
from typing import Dict, TYPE_CHECKING from typing import TYPE_CHECKING
from BaseClasses import Item, ItemClassification, Location, Region from BaseClasses import Item, ItemClassification, Location, Region
from . import items, locations from . import items, locations
@@ -6,7 +6,7 @@ if TYPE_CHECKING:
from . import NoitaWorld from . import NoitaWorld
def create_event(player: int, name: str) -> Item: def create_event_item(player: int, name: str) -> Item:
return items.NoitaItem(name, ItemClassification.progression, None, player) return items.NoitaItem(name, ItemClassification.progression, None, player)
@@ -16,13 +16,13 @@ def create_location(player: int, name: str, region: Region) -> Location:
def create_locked_location_event(player: int, region: Region, item: str) -> Location: def create_locked_location_event(player: int, region: Region, item: str) -> Location:
new_location = create_location(player, item, region) new_location = create_location(player, item, region)
new_location.place_locked_item(create_event(player, item)) new_location.place_locked_item(create_event_item(player, item))
region.locations.append(new_location) region.locations.append(new_location)
return new_location return new_location
def create_all_events(world: "NoitaWorld", created_regions: Dict[str, Region]) -> None: def create_all_events(world: "NoitaWorld", created_regions: dict[str, Region]) -> None:
for region_name, event in event_locks.items(): for region_name, event in event_locks.items():
region = created_regions[region_name] region = created_regions[region_name]
create_locked_location_event(world.player, region, event) create_locked_location_event(world.player, region, event)
@@ -31,7 +31,7 @@ def create_all_events(world: "NoitaWorld", created_regions: Dict[str, Region]) -
# Maps region names to event names # Maps region names to event names
event_locks: Dict[str, str] = { event_locks: dict[str, str] = {
"The Work": "Victory", "The Work": "Victory",
"Mines": "Portal to Holy Mountain 1", "Mines": "Portal to Holy Mountain 1",
"Coal Pits": "Portal to Holy Mountain 2", "Coal Pits": "Portal to Holy Mountain 2",

View File

@@ -1,6 +1,6 @@
import itertools import itertools
from collections import Counter from collections import Counter
from typing import Dict, List, NamedTuple, Set, TYPE_CHECKING from typing import NamedTuple, TYPE_CHECKING
from BaseClasses import Item, ItemClassification from BaseClasses import Item, ItemClassification
from .options import BossesAsChecks, VictoryCondition, ExtraOrbs from .options import BossesAsChecks, VictoryCondition, ExtraOrbs
@@ -27,12 +27,12 @@ def create_item(player: int, name: str) -> Item:
return NoitaItem(name, item_data.classification, item_data.code, player) return NoitaItem(name, item_data.classification, item_data.code, player)
def create_fixed_item_pool() -> List[str]: def create_fixed_item_pool() -> list[str]:
required_items: Dict[str, int] = {name: data.required_num for name, data in item_table.items()} required_items: dict[str, int] = {name: data.required_num for name, data in item_table.items()}
return list(Counter(required_items).elements()) return list(Counter(required_items).elements())
def create_orb_items(victory_condition: VictoryCondition, extra_orbs: ExtraOrbs) -> List[str]: def create_orb_items(victory_condition: VictoryCondition, extra_orbs: ExtraOrbs) -> list[str]:
orb_count = extra_orbs.value orb_count = extra_orbs.value
if victory_condition == VictoryCondition.option_pure_ending: if victory_condition == VictoryCondition.option_pure_ending:
orb_count = orb_count + 11 orb_count = orb_count + 11
@@ -41,15 +41,15 @@ def create_orb_items(victory_condition: VictoryCondition, extra_orbs: ExtraOrbs)
return ["Orb" for _ in range(orb_count)] return ["Orb" for _ in range(orb_count)]
def create_spatial_awareness_item(bosses_as_checks: BossesAsChecks) -> List[str]: 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 [] return ["Spatial Awareness Perk"] if bosses_as_checks.value >= BossesAsChecks.option_all_bosses else []
def create_kantele(victory_condition: VictoryCondition) -> List[str]: def create_kantele(victory_condition: VictoryCondition) -> list[str]:
return ["Kantele"] if victory_condition.value >= VictoryCondition.option_pure_ending else [] return ["Kantele"] if victory_condition.value >= VictoryCondition.option_pure_ending else []
def create_random_items(world: NoitaWorld, 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() filler_pool = weights.copy()
if not world.options.bad_effects: if not world.options.bad_effects:
filler_pool["Trap"] = 0 filler_pool["Trap"] = 0
@@ -87,7 +87,7 @@ def create_all_items(world: NoitaWorld) -> None:
# 110000 - 110032 # 110000 - 110032
item_table: Dict[str, ItemData] = { item_table: dict[str, ItemData] = {
"Trap": ItemData(110000, "Traps", ItemClassification.trap), "Trap": ItemData(110000, "Traps", ItemClassification.trap),
"Extra Max HP": ItemData(110001, "Pickups", ItemClassification.useful), "Extra Max HP": ItemData(110001, "Pickups", ItemClassification.useful),
"Spell Refresher": ItemData(110002, "Pickups", ItemClassification.filler), "Spell Refresher": ItemData(110002, "Pickups", ItemClassification.filler),
@@ -122,7 +122,7 @@ item_table: Dict[str, ItemData] = {
"Broken Wand": ItemData(110031, "Items", ItemClassification.filler), "Broken Wand": ItemData(110031, "Items", ItemClassification.filler),
} }
shop_only_filler_weights: Dict[str, int] = { shop_only_filler_weights: dict[str, int] = {
"Trap": 15, "Trap": 15,
"Extra Max HP": 25, "Extra Max HP": 25,
"Spell Refresher": 20, "Spell Refresher": 20,
@@ -135,7 +135,7 @@ shop_only_filler_weights: Dict[str, int] = {
"Extra Life Perk": 10, "Extra Life Perk": 10,
} }
filler_weights: Dict[str, int] = { filler_weights: dict[str, int] = {
**shop_only_filler_weights, **shop_only_filler_weights,
"Gold (200)": 15, "Gold (200)": 15,
"Gold (1000)": 6, "Gold (1000)": 6,
@@ -152,22 +152,10 @@ filler_weights: Dict[str, int] = {
} }
# These helper functions make the comprehensions below more readable filler_items: list[str] = list(filter(lambda item: item_table[item].classification == ItemClassification.filler,
def get_item_group(item_name: str) -> str: item_table.keys()))
return item_table[item_name].group item_name_to_id: dict[str, int] = {name: data.code for name, data in item_table.items()}
item_name_groups: dict[str, set[str]] = {
def item_is_filler(item_name: str) -> bool: group: set(item_names) for group, item_names in itertools.groupby(item_table, lambda item: item_table[item].group)
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)
} }

View File

@@ -1,6 +1,6 @@
# Locations are specific points that you would obtain an item at. # Locations are specific points that you would obtain an item at.
from enum import IntEnum from enum import IntEnum
from typing import Dict, NamedTuple, Optional, Set from typing import NamedTuple
from BaseClasses import Location from BaseClasses import Location
@@ -27,7 +27,7 @@ class LocationFlag(IntEnum):
# Only the first Hidden Chest and Pedestal are mapped here, the others are created in Regions. # 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. # ltype key: "Chest" = Hidden Chests, "Pedestal" = Pedestals, "Boss" = Boss, "Orb" = Orb.
# 110000-110671 # 110000-110671
location_region_mapping: Dict[str, Dict[str, LocationData]] = { location_region_mapping: dict[str, dict[str, LocationData]] = {
"Coal Pits Holy Mountain": { "Coal Pits Holy Mountain": {
"Coal Pits Holy Mountain Shop Item 1": LocationData(110000), "Coal Pits Holy Mountain Shop Item 1": LocationData(110000),
"Coal Pits Holy Mountain Shop Item 2": LocationData(110001), "Coal Pits Holy Mountain Shop Item 2": LocationData(110001),
@@ -207,15 +207,15 @@ location_region_mapping: Dict[str, Dict[str, LocationData]] = {
} }
def make_location_range(location_name: str, base_id: int, amt: int) -> Dict[str, int]: def make_location_range(location_name: str, base_id: int, amt: int) -> dict[str, int]:
if amt == 1: if amt == 1:
return {location_name: base_id} return {location_name: base_id}
return {f"{location_name} {i+1}": base_id + i for i in range(amt)} 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(), location_name_groups: dict[str, set[str]] = {"Shop": set(), "Orb": set(), "Boss": set(), "Chest": set(),
"Pedestal": set()} "Pedestal": set()}
location_name_to_id: Dict[str, int] = {} location_name_to_id: dict[str, int] = {}
for region_name, location_group in location_region_mapping.items(): for region_name, location_group in location_region_mapping.items():

View File

@@ -1,5 +1,5 @@
# Regions are areas in your game that you travel to. # Regions are areas in your game that you travel to.
from typing import Dict, List, TYPE_CHECKING from typing import TYPE_CHECKING
from BaseClasses import Entrance, Region from BaseClasses import Entrance, Region
from . import locations from . import locations
@@ -36,28 +36,21 @@ def create_region(world: "NoitaWorld", region_name: str) -> Region:
return new_region return new_region
def create_regions(world: "NoitaWorld") -> Dict[str, Region]: def create_regions(world: "NoitaWorld") -> dict[str, Region]:
return {name: create_region(world, name) for name in noita_regions} return {name: create_region(world, 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 = 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`. # Creates connections based on our access mapping in `noita_connections`.
def create_connections(player: int, regions: Dict[str, Region]) -> None: def create_connections(regions: dict[str, Region]) -> None:
for source, destinations in noita_connections.items(): for source, destinations in noita_connections.items():
new_entrances = [create_entrance(player, source, destination, regions) for destination in destinations] for destination in destinations:
regions[source].exits = new_entrances regions[source].connect(regions[destination])
# Creates all regions and connections. Called from NoitaWorld. # Creates all regions and connections. Called from NoitaWorld.
def create_all_regions_and_connections(world: "NoitaWorld") -> None: def create_all_regions_and_connections(world: "NoitaWorld") -> None:
created_regions = create_regions(world) created_regions = create_regions(world)
create_connections(world.player, created_regions) create_connections(created_regions)
create_all_events(world, created_regions) create_all_events(world, created_regions)
world.multiworld.regions += created_regions.values() world.multiworld.regions += created_regions.values()
@@ -75,7 +68,7 @@ def create_all_regions_and_connections(world: "NoitaWorld") -> None:
# - Lake is connected to The Laboratory, since the bosses are hard without specific set-ups (which means late game) # - Lake is connected to The Laboratory, since the bosses are hard without specific set-ups (which means late game)
# - Snowy Depths connects to Lava Lake orb since you need digging for it, so fairly early is acceptable # - Snowy Depths connects to Lava Lake orb since you need digging for it, so fairly early is acceptable
# - Ancient Laboratory is connected to the Coal Pits, so that Ylialkemisti isn't sphere 1 # - Ancient Laboratory is connected to the Coal Pits, so that Ylialkemisti isn't sphere 1
noita_connections: Dict[str, List[str]] = { noita_connections: dict[str, list[str]] = {
"Menu": ["Forest"], "Menu": ["Forest"],
"Forest": ["Mines", "Floating Island", "Desert", "Snowy Wasteland"], "Forest": ["Mines", "Floating Island", "Desert", "Snowy Wasteland"],
"Frozen Vault": ["The Vault"], "Frozen Vault": ["The Vault"],
@@ -117,4 +110,4 @@ noita_connections: Dict[str, List[str]] = {
### ###
} }
noita_regions: List[str] = sorted(set(noita_connections.keys()).union(*noita_connections.values())) noita_regions: list[str] = sorted(set(noita_connections.keys()).union(*noita_connections.values()))

View File

@@ -1,6 +1,5 @@
from typing import List, NamedTuple, Set, TYPE_CHECKING from typing import NamedTuple, TYPE_CHECKING
from BaseClasses import CollectionState
from . import items, locations from . import items, locations
from .options import BossesAsChecks, VictoryCondition from .options import BossesAsChecks, VictoryCondition
from worlds.generic import Rules as GenericRules from worlds.generic import Rules as GenericRules
@@ -16,7 +15,7 @@ class EntranceLock(NamedTuple):
items_needed: int items_needed: int
entrance_locks: List[EntranceLock] = [ entrance_locks: list[EntranceLock] = [
EntranceLock("Mines", "Coal Pits Holy Mountain", "Portal to Holy Mountain 1", 1), 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("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("Snowy Depths", "Hiisi Base Holy Mountain", "Portal to Holy Mountain 3", 3),
@@ -27,7 +26,7 @@ entrance_locks: List[EntranceLock] = [
] ]
holy_mountain_regions: List[str] = [ holy_mountain_regions: list[str] = [
"Coal Pits Holy Mountain", "Coal Pits Holy Mountain",
"Snowy Depths Holy Mountain", "Snowy Depths Holy Mountain",
"Hiisi Base Holy Mountain", "Hiisi Base Holy Mountain",
@@ -38,7 +37,7 @@ holy_mountain_regions: List[str] = [
] ]
wand_tiers: List[str] = [ wand_tiers: list[str] = [
"Wand (Tier 1)", # Coal Pits "Wand (Tier 1)", # Coal Pits
"Wand (Tier 2)", # Snowy Depths "Wand (Tier 2)", # Snowy Depths
"Wand (Tier 3)", # Hiisi Base "Wand (Tier 3)", # Hiisi Base
@@ -48,29 +47,21 @@ wand_tiers: List[str] = [
] ]
items_hidden_from_shops: Set[str] = {"Gold (200)", "Gold (1000)", "Potion", "Random Potion", "Secret Potion", 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", "Chaos Die", "Greed Die", "Kammi", "Refreshing Gourd", "Sädekivi", "Broken Wand",
"Powder Pouch"} "Powder Pouch"}
perk_list: List[str] = list(filter(items.item_is_perk, items.item_table.keys())) perk_list: list[str] = list(filter(lambda item: items.item_table[item].group == "Perks", items.item_table.keys()))
# ---------------- # ----------------
# Helper Functions # Helper Function
# ---------------- # ----------------
def has_perk_count(state: CollectionState, player: int, amount: int) -> bool: def forbid_items_at_locations(world: "NoitaWorld", shop_locations: set[str], forbidden_items: set[str]) -> None:
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]) -> None:
for shop_location in shop_locations: for shop_location in shop_locations:
location = world.multiworld.get_location(shop_location, world.player) location = world.get_location(shop_location)
GenericRules.forbid_items_for_player(location, forbidden_items, world.player) GenericRules.forbid_items_for_player(location, forbidden_items, world.player)
@@ -104,38 +95,38 @@ def ban_early_high_tier_wands(world: "NoitaWorld") -> None:
def lock_holy_mountains_into_spheres(world: "NoitaWorld") -> None: def lock_holy_mountains_into_spheres(world: "NoitaWorld") -> None:
for lock in entrance_locks: for lock in entrance_locks:
location = world.multiworld.get_entrance(f"From {lock.source} To {lock.destination}", world.player) location = world.get_entrance(f"{lock.source} -> {lock.destination}")
GenericRules.set_rule(location, lambda state, evt=lock.event: state.has(evt, world.player)) GenericRules.set_rule(location, lambda state, evt=lock.event: state.has(evt, world.player))
def holy_mountain_unlock_conditions(world: "NoitaWorld") -> None: def holy_mountain_unlock_conditions(world: "NoitaWorld") -> None:
victory_condition = world.options.victory_condition.value victory_condition = world.options.victory_condition.value
for lock in entrance_locks: for lock in entrance_locks:
location = world.multiworld.get_location(lock.event, world.player) location = world.get_location(lock.event)
if victory_condition == VictoryCondition.option_greed_ending: if victory_condition == VictoryCondition.option_greed_ending:
location.access_rule = lambda state, items_needed=lock.items_needed: ( location.access_rule = lambda state, items_needed=lock.items_needed: (
has_perk_count(state, world.player, items_needed//2) state.has_group_unique("Perks", world.player, items_needed // 2)
) )
elif victory_condition == VictoryCondition.option_pure_ending: elif victory_condition == VictoryCondition.option_pure_ending:
location.access_rule = lambda state, items_needed=lock.items_needed: ( location.access_rule = lambda state, items_needed=lock.items_needed: (
has_perk_count(state, world.player, items_needed//2) and state.has_group_unique("Perks", world.player, items_needed // 2) and
has_orb_count(state, world.player, items_needed) state.has("Orb", world.player, items_needed)
) )
elif victory_condition == VictoryCondition.option_peaceful_ending: elif victory_condition == VictoryCondition.option_peaceful_ending:
location.access_rule = lambda state, items_needed=lock.items_needed: ( location.access_rule = lambda state, items_needed=lock.items_needed: (
has_perk_count(state, world.player, items_needed//2) and state.has_group_unique("Perks", world.player, items_needed // 2) and
has_orb_count(state, world.player, items_needed * 3) state.has("Orb", world.player, items_needed * 3)
) )
def biome_unlock_conditions(world: "NoitaWorld") -> None: def biome_unlock_conditions(world: "NoitaWorld") -> None:
lukki_entrances = world.multiworld.get_region("Lukki Lair", world.player).entrances lukki_entrances = world.get_region("Lukki Lair").entrances
magical_entrances = world.multiworld.get_region("Magical Temple", world.player).entrances magical_entrances = world.get_region("Magical Temple").entrances
wizard_entrances = world.multiworld.get_region("Wizards' Den", world.player).entrances wizard_entrances = world.get_region("Wizards' Den").entrances
for entrance in lukki_entrances: for entrance in lukki_entrances:
entrance.access_rule = lambda state: state.has("Melee Immunity Perk", world.player) and\ entrance.access_rule = lambda state: (
state.has("All-Seeing Eye Perk", world.player) state.has_all(("Melee Immunity Perk", "All-Seeing Eye Perk"), world.player))
for entrance in magical_entrances: for entrance in magical_entrances:
entrance.access_rule = lambda state: state.has("All-Seeing Eye Perk", world.player) entrance.access_rule = lambda state: state.has("All-Seeing Eye Perk", world.player)
for entrance in wizard_entrances: for entrance in wizard_entrances:
@@ -144,12 +135,12 @@ def biome_unlock_conditions(world: "NoitaWorld") -> None:
def victory_unlock_conditions(world: "NoitaWorld") -> None: def victory_unlock_conditions(world: "NoitaWorld") -> None:
victory_condition = world.options.victory_condition.value victory_condition = world.options.victory_condition.value
victory_location = world.multiworld.get_location("Victory", world.player) victory_location = world.get_location("Victory")
if victory_condition == VictoryCondition.option_pure_ending: if victory_condition == VictoryCondition.option_pure_ending:
victory_location.access_rule = lambda state: has_orb_count(state, world.player, 11) victory_location.access_rule = lambda state: state.has("Orb", world.player, 11)
elif victory_condition == VictoryCondition.option_peaceful_ending: elif victory_condition == VictoryCondition.option_peaceful_ending:
victory_location.access_rule = lambda state: has_orb_count(state, world.player, 33) victory_location.access_rule = lambda state: state.has("Orb", world.player, 33)
# ---------------- # ----------------
@@ -168,5 +159,5 @@ def create_all_rules(world: "NoitaWorld") -> None:
# Prevent the Map perk (used to find Toveri) from being on Toveri (boss) # Prevent the Map perk (used to find Toveri) from being on Toveri (boss)
if world.options.bosses_as_checks.value >= BossesAsChecks.option_all_bosses: if world.options.bosses_as_checks.value >= BossesAsChecks.option_all_bosses:
toveri = world.multiworld.get_location("Toveri", world.player) toveri = world.get_location("Toveri")
GenericRules.forbid_items_for_player(toveri, {"Spatial Awareness Perk"}, world.player) GenericRules.forbid_items_for_player(toveri, {"Spatial Awareness Perk"}, world.player)