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()
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).")
# 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 . import items, locations
@@ -6,7 +6,7 @@ if TYPE_CHECKING:
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)
@@ -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:
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)
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():
region = created_regions[region_name]
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
event_locks: Dict[str, str] = {
event_locks: dict[str, str] = {
"The Work": "Victory",
"Mines": "Portal to Holy Mountain 1",
"Coal Pits": "Portal to Holy Mountain 2",

View File

@@ -1,6 +1,6 @@
import itertools
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 .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)
def create_fixed_item_pool() -> List[str]:
required_items: Dict[str, int] = {name: data.required_num for name, data in item_table.items()}
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, extra_orbs: ExtraOrbs) -> List[str]:
def create_orb_items(victory_condition: VictoryCondition, extra_orbs: ExtraOrbs) -> list[str]:
orb_count = extra_orbs.value
if victory_condition == VictoryCondition.option_pure_ending:
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)]
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 []
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 []
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()
if not world.options.bad_effects:
filler_pool["Trap"] = 0
@@ -87,7 +87,7 @@ def create_all_items(world: NoitaWorld) -> None:
# 110000 - 110032
item_table: Dict[str, ItemData] = {
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),
@@ -122,7 +122,7 @@ item_table: Dict[str, ItemData] = {
"Broken Wand": ItemData(110031, "Items", ItemClassification.filler),
}
shop_only_filler_weights: Dict[str, int] = {
shop_only_filler_weights: dict[str, int] = {
"Trap": 15,
"Extra Max HP": 25,
"Spell Refresher": 20,
@@ -135,7 +135,7 @@ shop_only_filler_weights: Dict[str, int] = {
"Extra Life Perk": 10,
}
filler_weights: Dict[str, int] = {
filler_weights: dict[str, int] = {
**shop_only_filler_weights,
"Gold (200)": 15,
"Gold (1000)": 6,
@@ -152,22 +152,10 @@ filler_weights: Dict[str, int] = {
}
# These helper functions make the comprehensions below more readable
def get_item_group(item_name: str) -> str:
return item_table[item_name].group
filler_items: list[str] = list(filter(lambda item: item_table[item].classification == ItemClassification.filler,
item_table.keys()))
item_name_to_id: dict[str, int] = {name: data.code for name, data in item_table.items()}
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)
item_name_groups: dict[str, set[str]] = {
group: set(item_names) for group, item_names in itertools.groupby(item_table, lambda item: item_table[item].group)
}

View File

@@ -1,6 +1,6 @@
# Locations are specific points that you would obtain an item at.
from enum import IntEnum
from typing import Dict, NamedTuple, Optional, Set
from typing import NamedTuple
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.
# ltype key: "Chest" = Hidden Chests, "Pedestal" = Pedestals, "Boss" = Boss, "Orb" = Orb.
# 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 Shop Item 1": LocationData(110000),
"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:
return {location_name: base_id}
return {f"{location_name} {i+1}": base_id + i for i in range(amt)}
location_name_groups: Dict[str, Set[str]] = {"Shop": set(), "Orb": set(), "Boss": set(), "Chest": set(),
location_name_groups: dict[str, set[str]] = {"Shop": set(), "Orb": set(), "Boss": set(), "Chest": 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():

View File

@@ -1,5 +1,5 @@
# 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 . import locations
@@ -36,28 +36,21 @@ def create_region(world: "NoitaWorld", region_name: str) -> 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}
# 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`.
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():
new_entrances = [create_entrance(player, source, destination, regions) for destination in destinations]
regions[source].exits = new_entrances
for destination in destinations:
regions[source].connect(regions[destination])
# Creates all regions and connections. Called from NoitaWorld.
def create_all_regions_and_connections(world: "NoitaWorld") -> None:
created_regions = create_regions(world)
create_connections(world.player, created_regions)
create_connections(created_regions)
create_all_events(world, created_regions)
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)
# - 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
noita_connections: Dict[str, List[str]] = {
noita_connections: dict[str, list[str]] = {
"Menu": ["Forest"],
"Forest": ["Mines", "Floating Island", "Desert", "Snowy Wasteland"],
"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 .options import BossesAsChecks, VictoryCondition
from worlds.generic import Rules as GenericRules
@@ -16,7 +15,7 @@ class EntranceLock(NamedTuple):
items_needed: int
entrance_locks: List[EntranceLock] = [
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),
@@ -27,7 +26,7 @@ entrance_locks: List[EntranceLock] = [
]
holy_mountain_regions: List[str] = [
holy_mountain_regions: list[str] = [
"Coal Pits Holy Mountain",
"Snowy Depths 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 2)", # Snowy Depths
"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",
"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:
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:
def forbid_items_at_locations(world: "NoitaWorld", shop_locations: set[str], forbidden_items: set[str]) -> None:
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)
@@ -104,38 +95,38 @@ def ban_early_high_tier_wands(world: "NoitaWorld") -> None:
def lock_holy_mountains_into_spheres(world: "NoitaWorld") -> None:
for lock in entrance_locks:
location = world.multiworld.get_entrance(f"From {lock.source} To {lock.destination}", world.player)
location = world.get_entrance(f"{lock.source} -> {lock.destination}")
GenericRules.set_rule(location, lambda state, evt=lock.event: state.has(evt, world.player))
def holy_mountain_unlock_conditions(world: "NoitaWorld") -> None:
victory_condition = world.options.victory_condition.value
for lock in entrance_locks:
location = world.multiworld.get_location(lock.event, world.player)
location = world.get_location(lock.event)
if victory_condition == VictoryCondition.option_greed_ending:
location.access_rule = lambda state, items_needed=lock.items_needed: (
has_perk_count(state, world.player, items_needed//2)
state.has_group_unique("Perks", world.player, items_needed // 2)
)
elif victory_condition == VictoryCondition.option_pure_ending:
location.access_rule = lambda state, items_needed=lock.items_needed: (
has_perk_count(state, world.player, items_needed//2) and
has_orb_count(state, world.player, items_needed)
state.has_group_unique("Perks", world.player, items_needed // 2) and
state.has("Orb", world.player, items_needed)
)
elif victory_condition == VictoryCondition.option_peaceful_ending:
location.access_rule = lambda state, items_needed=lock.items_needed: (
has_perk_count(state, world.player, items_needed//2) and
has_orb_count(state, world.player, items_needed * 3)
state.has_group_unique("Perks", world.player, items_needed // 2) and
state.has("Orb", world.player, items_needed * 3)
)
def biome_unlock_conditions(world: "NoitaWorld") -> None:
lukki_entrances = world.multiworld.get_region("Lukki Lair", world.player).entrances
magical_entrances = world.multiworld.get_region("Magical Temple", world.player).entrances
wizard_entrances = world.multiworld.get_region("Wizards' Den", world.player).entrances
lukki_entrances = world.get_region("Lukki Lair").entrances
magical_entrances = world.get_region("Magical Temple").entrances
wizard_entrances = world.get_region("Wizards' Den").entrances
for entrance in lukki_entrances:
entrance.access_rule = lambda state: state.has("Melee Immunity Perk", world.player) and\
state.has("All-Seeing Eye Perk", world.player)
entrance.access_rule = lambda state: (
state.has_all(("Melee Immunity Perk", "All-Seeing Eye Perk"), world.player))
for entrance in magical_entrances:
entrance.access_rule = lambda state: state.has("All-Seeing Eye Perk", world.player)
for entrance in wizard_entrances:
@@ -144,12 +135,12 @@ def biome_unlock_conditions(world: "NoitaWorld") -> None:
def victory_unlock_conditions(world: "NoitaWorld") -> None:
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:
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:
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)
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)