From 834b6e35b450a471e26f0e5a4c5c367faf760f7a Mon Sep 17 00:00:00 2001 From: Fabian Dill Date: Thu, 18 Jan 2024 01:52:33 +0100 Subject: [PATCH 01/38] Setup: auto update vc redist (#2502) --- inno_setup.iss | 2 +- setup.py | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/inno_setup.iss b/inno_setup.iss index be5de320a1c..b122cdc00b1 100644 --- a/inno_setup.iss +++ b/inno_setup.iss @@ -197,7 +197,7 @@ begin begin // Is the installed version at least the packaged one ? Log('VC Redist x64 Version : found ' + strVersion); - Result := (CompareStr(strVersion, 'v14.32.31332') < 0); + Result := (CompareStr(strVersion, 'v14.38.33130') < 0); end else begin diff --git a/setup.py b/setup.py index 05e923ed3f0..9b28715ae98 100644 --- a/setup.py +++ b/setup.py @@ -349,6 +349,18 @@ def run(self): for folder in sdl2.dep_bins + glew.dep_bins: shutil.copytree(folder, self.libfolder, dirs_exist_ok=True) print(f"copying {folder} -> {self.libfolder}") + # windows needs Visual Studio C++ Redistributable + # Installer works for x64 and arm64 + print("Downloading VC Redist") + import certifi + import ssl + context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH, cafile=certifi.where()) + with urllib.request.urlopen(r"https://aka.ms/vs/17/release/vc_redist.x64.exe", + context=context) as download: + vc_redist = download.read() + print(f"Download complete, {len(vc_redist) / 1024 / 1024:.2f} MBytes downloaded.", ) + with open("VC_redist.x64.exe", "wb") as vc_file: + vc_file.write(vc_redist) for data in self.extra_data: self.installfile(Path(data)) From 4c901dcfc0554fd2d2774bb73499655d3ce1e99a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dana=C3=ABl=20V?= <104455676+ReverM@users.noreply.github.com> Date: Wed, 17 Jan 2024 19:56:34 -0500 Subject: [PATCH 02/38] TUNIC: Change Tunic to TUNIC (#2720) --- worlds/tunic/__init__.py | 8 ++++---- worlds/tunic/docs/{en_Tunic.md => en_TUNIC.md} | 0 worlds/tunic/er_scripts.py | 4 ++-- worlds/tunic/test/__init__.py | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) rename worlds/tunic/docs/{en_Tunic.md => en_TUNIC.md} (100%) diff --git a/worlds/tunic/__init__.py b/worlds/tunic/__init__.py index b946ea8e303..d8311de856f 100644 --- a/worlds/tunic/__init__.py +++ b/worlds/tunic/__init__.py @@ -24,15 +24,15 @@ class TunicWeb(WebWorld): ) ] theme = "grassFlowers" - game = "Tunic" + game = "TUNIC" class TunicItem(Item): - game: str = "Tunic" + game: str = "TUNIC" class TunicLocation(Location): - game: str = "Tunic" + game: str = "TUNIC" class TunicWorld(World): @@ -41,7 +41,7 @@ class TunicWorld(World): about a small fox on a big adventure. Stranded on a mysterious beach, armed with only your own curiosity, you will confront colossal beasts, collect strange and powerful items, and unravel long-lost secrets. Be brave, tiny fox! """ - game = "Tunic" + game = "TUNIC" web = TunicWeb() data_version = 2 diff --git a/worlds/tunic/docs/en_Tunic.md b/worlds/tunic/docs/en_TUNIC.md similarity index 100% rename from worlds/tunic/docs/en_Tunic.md rename to worlds/tunic/docs/en_TUNIC.md diff --git a/worlds/tunic/er_scripts.py b/worlds/tunic/er_scripts.py index 84b97e13daa..4d640b2fda7 100644 --- a/worlds/tunic/er_scripts.py +++ b/worlds/tunic/er_scripts.py @@ -10,11 +10,11 @@ class TunicERItem(Item): - game: str = "Tunic" + game: str = "TUNIC" class TunicERLocation(Location): - game: str = "Tunic" + game: str = "TUNIC" def create_er_regions(world: "TunicWorld") -> Tuple[Dict[Portal, Portal], Dict[int, str]]: diff --git a/worlds/tunic/test/__init__.py b/worlds/tunic/test/__init__.py index f8ab99d67d2..d7ae47f7d74 100644 --- a/worlds/tunic/test/__init__.py +++ b/worlds/tunic/test/__init__.py @@ -2,5 +2,5 @@ class TunicTestBase(WorldTestBase): - game = "Tunic" + game = "TUNIC" player: int = 1 \ No newline at end of file From ec440b7785a6464d5f2e2caf16cafd0b48880219 Mon Sep 17 00:00:00 2001 From: Star Rauchenberger Date: Wed, 17 Jan 2024 19:58:48 -0500 Subject: [PATCH 03/38] Lingo: NORTH requires hint panels (#2732) --- worlds/lingo/data/LL1.yaml | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/worlds/lingo/data/LL1.yaml b/worlds/lingo/data/LL1.yaml index 32a7659b826..da78a5123df 100644 --- a/worlds/lingo/data/LL1.yaml +++ b/worlds/lingo/data/LL1.yaml @@ -1118,7 +1118,13 @@ id: Cross Room/Panel_north_missing colors: green tag: forbid - required_room: Outside The Bold + required_panel: + - room: Outside The Bold + panel: MOUTH + - room: Outside The Bold + panel: YEAST + - room: Outside The Bold + panel: WET DIAMONDS: id: Cross Room/Panel_diamonds_missing colors: green @@ -4414,9 +4420,14 @@ colors: blue tag: forbid required_panel: - room: The Bearer (West) - panel: SMILE - required_room: Outside The Bold + - room: The Bearer (West) + panel: SMILE + - room: Outside The Bold + panel: MOUTH + - room: Outside The Bold + panel: YEAST + - room: Outside The Bold + panel: WET Cross Tower (South): entrances: # No roof access The Bearer (North): From ac7b707e3ebe1bfcdd332157239b4ace74295ae5 Mon Sep 17 00:00:00 2001 From: Bicoloursnake <60069210+Bicoloursnake@users.noreply.github.com> Date: Wed, 17 Jan 2024 20:18:03 -0500 Subject: [PATCH 04/38] OOT: Adjust the Logic Trick Keys to be an ordered object (#2736) --- worlds/oot/Options.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/worlds/oot/Options.py b/worlds/oot/Options.py index 120027e29df..2543cdc715c 100644 --- a/worlds/oot/Options.py +++ b/worlds/oot/Options.py @@ -1271,7 +1271,7 @@ class LogicTricks(OptionList): https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/oot/LogicTricks.py """ display_name = "Logic Tricks" - valid_keys = frozenset(normalized_name_tricks) + valid_keys = tuple(normalized_name_tricks.keys()) valid_keys_casefold = True From 1307754f0291aa117ace540e54574923487e0a46 Mon Sep 17 00:00:00 2001 From: zig-for Date: Fri, 19 Jan 2024 12:14:26 -0800 Subject: [PATCH 05/38] LADX: music shuffle (#2101) --- worlds/ladx/Options.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/worlds/ladx/Options.py b/worlds/ladx/Options.py index 691891c0b35..117242208be 100644 --- a/worlds/ladx/Options.py +++ b/worlds/ladx/Options.py @@ -399,6 +399,26 @@ class Palette(Choice): option_pink = 4 option_inverted = 5 +class Music(Choice, LADXROption): + """ + [Vanilla] Regular Music + [Shuffled] Shuffled Music + [Off] No music + """ + ladxr_name = "music" + option_vanilla = 0 + option_shuffled = 1 + option_off = 2 + + + def to_ladxr_option(self, all_options): + s = "" + if self.value == self.option_shuffled: + s = "random" + elif self.value == self.option_off: + s = "off" + return self.ladxr_name, s + class WarpImprovements(DefaultOffToggle): """ [On] Adds remake style warp screen to the game. Choose your warp destination on the map after jumping in a portal and press B to select. @@ -444,6 +464,7 @@ class AdditionalWarpPoints(DefaultOffToggle): 'shuffle_maps': ShuffleMaps, 'shuffle_compasses': ShuffleCompasses, 'shuffle_stone_beaks': ShuffleStoneBeaks, + 'music': Music, 'music_change_condition': MusicChangeCondition, 'nag_messages': NagMessages, 'ap_title_screen': APTitleScreen, From 5f9ce2b7b60fe05a54ccd3498c67da7cac361a63 Mon Sep 17 00:00:00 2001 From: Scipio Wright Date: Fri, 19 Jan 2024 15:31:45 -0500 Subject: [PATCH 06/38] Noita: Update to use new Options API (#2370) Reworking the options to make it work with the new options API. Also reworked stuff in several spots to use world: NoitaWorld instead of multiworld: MultiWorld --- worlds/noita/Events.py | 42 ----- worlds/noita/Rules.py | 166 ------------------- worlds/noita/__init__.py | 31 ++-- worlds/noita/events.py | 43 +++++ worlds/noita/{Items.py => items.py} | 44 ++--- worlds/noita/{Locations.py => locations.py} | 23 +-- worlds/noita/{Options.py => options.py} | 30 ++-- worlds/noita/{Regions.py => regions.py} | 64 ++++---- worlds/noita/rules.py | 172 ++++++++++++++++++++ 9 files changed, 314 insertions(+), 301 deletions(-) delete mode 100644 worlds/noita/Events.py delete mode 100644 worlds/noita/Rules.py create mode 100644 worlds/noita/events.py rename worlds/noita/{Items.py => items.py} (82%) rename worlds/noita/{Locations.py => locations.py} (94%) rename worlds/noita/{Options.py => options.py} (87%) rename worlds/noita/{Regions.py => regions.py} (61%) create mode 100644 worlds/noita/rules.py diff --git a/worlds/noita/Events.py b/worlds/noita/Events.py deleted file mode 100644 index e759d38c6c7..00000000000 --- a/worlds/noita/Events.py +++ /dev/null @@ -1,42 +0,0 @@ -from typing import Dict - -from BaseClasses import Item, ItemClassification, Location, MultiWorld, Region -from . import Items, Locations - - -def create_event(player: int, name: str) -> Item: - return Items.NoitaItem(name, ItemClassification.progression, None, player) - - -def create_location(player: int, name: str, region: Region) -> Location: - return Locations.NoitaLocation(player, name, None, region) - - -def create_locked_location_event(multiworld: MultiWorld, player: int, region_name: str, item: str) -> Location: - region = multiworld.get_region(region_name, player) - - new_location = create_location(player, item, region) - new_location.place_locked_item(create_event(player, item)) - - region.locations.append(new_location) - return new_location - - -def create_all_events(multiworld: MultiWorld, player: int) -> None: - for region, event in event_locks.items(): - create_locked_location_event(multiworld, player, region, event) - - multiworld.completion_condition[player] = lambda state: state.has("Victory", player) - - -# Maps region names to event names -event_locks: Dict[str, str] = { - "The Work": "Victory", - "Mines": "Portal to Holy Mountain 1", - "Coal Pits": "Portal to Holy Mountain 2", - "Snowy Depths": "Portal to Holy Mountain 3", - "Hiisi Base": "Portal to Holy Mountain 4", - "Underground Jungle": "Portal to Holy Mountain 5", - "The Vault": "Portal to Holy Mountain 6", - "Temple of the Art": "Portal to Holy Mountain 7", -} diff --git a/worlds/noita/Rules.py b/worlds/noita/Rules.py deleted file mode 100644 index 8190b80dc71..00000000000 --- a/worlds/noita/Rules.py +++ /dev/null @@ -1,166 +0,0 @@ -from typing import List, NamedTuple, Set - -from BaseClasses import CollectionState, MultiWorld -from . import Items, Locations -from .Options import BossesAsChecks, VictoryCondition -from worlds.generic import Rules as GenericRules - - -class EntranceLock(NamedTuple): - source: str - destination: str - event: str - items_needed: int - - -entrance_locks: List[EntranceLock] = [ - EntranceLock("Mines", "Coal Pits Holy Mountain", "Portal to Holy Mountain 1", 1), - EntranceLock("Coal Pits", "Snowy Depths Holy Mountain", "Portal to Holy Mountain 2", 2), - EntranceLock("Snowy Depths", "Hiisi Base Holy Mountain", "Portal to Holy Mountain 3", 3), - EntranceLock("Hiisi Base", "Underground Jungle Holy Mountain", "Portal to Holy Mountain 4", 4), - EntranceLock("Underground Jungle", "Vault Holy Mountain", "Portal to Holy Mountain 5", 5), - EntranceLock("The Vault", "Temple of the Art Holy Mountain", "Portal to Holy Mountain 6", 6), - EntranceLock("Temple of the Art", "Laboratory Holy Mountain", "Portal to Holy Mountain 7", 7), -] - - -holy_mountain_regions: List[str] = [ - "Coal Pits Holy Mountain", - "Snowy Depths Holy Mountain", - "Hiisi Base Holy Mountain", - "Underground Jungle Holy Mountain", - "Vault Holy Mountain", - "Temple of the Art Holy Mountain", - "Laboratory Holy Mountain", -] - - -wand_tiers: List[str] = [ - "Wand (Tier 1)", # Coal Pits - "Wand (Tier 2)", # Snowy Depths - "Wand (Tier 3)", # Hiisi Base - "Wand (Tier 4)", # Underground Jungle - "Wand (Tier 5)", # The Vault - "Wand (Tier 6)", # Temple of the Art -] - -items_hidden_from_shops: List[str] = ["Gold (200)", "Gold (1000)", "Potion", "Random Potion", "Secret Potion", - "Chaos Die", "Greed Die", "Kammi", "Refreshing Gourd", "Sädekivi", "Broken Wand", - "Powder Pouch"] - -perk_list: List[str] = list(filter(Items.item_is_perk, Items.item_table.keys())) - - -# ---------------- -# Helper Functions -# ---------------- - - -def has_perk_count(state: CollectionState, player: int, amount: int) -> bool: - return sum(state.count(perk, player) for perk in perk_list) >= amount - - -def has_orb_count(state: CollectionState, player: int, amount: int) -> bool: - return state.count("Orb", player) >= amount - - -def forbid_items_at_location(multiworld: MultiWorld, location_name: str, items: Set[str], player: int): - location = multiworld.get_location(location_name, player) - GenericRules.forbid_items_for_player(location, items, player) - - -# ---------------- -# Rule Functions -# ---------------- - - -# Prevent gold and potions from appearing as purchasable items in shops (because physics will destroy them) -def ban_items_from_shops(multiworld: MultiWorld, player: int) -> None: - for location_name in Locations.location_name_to_id.keys(): - if "Shop Item" in location_name: - forbid_items_at_location(multiworld, location_name, items_hidden_from_shops, player) - - -# Prevent high tier wands from appearing in early Holy Mountain shops -def ban_early_high_tier_wands(multiworld: MultiWorld, player: int) -> None: - for i, region_name in enumerate(holy_mountain_regions): - wands_to_forbid = wand_tiers[i+1:] - - locations_in_region = Locations.location_region_mapping[region_name].keys() - for location_name in locations_in_region: - forbid_items_at_location(multiworld, location_name, wands_to_forbid, player) - - # Prevent high tier wands from appearing in the Secret shop - wands_to_forbid = wand_tiers[3:] - locations_in_region = Locations.location_region_mapping["Secret Shop"].keys() - for location_name in locations_in_region: - forbid_items_at_location(multiworld, location_name, wands_to_forbid, player) - - -def lock_holy_mountains_into_spheres(multiworld: MultiWorld, player: int) -> None: - for lock in entrance_locks: - location = multiworld.get_entrance(f"From {lock.source} To {lock.destination}", player) - GenericRules.set_rule(location, lambda state, evt=lock.event: state.has(evt, player)) - - -def holy_mountain_unlock_conditions(multiworld: MultiWorld, player: int) -> None: - victory_condition = multiworld.victory_condition[player].value - for lock in entrance_locks: - location = multiworld.get_location(lock.event, player) - - if victory_condition == VictoryCondition.option_greed_ending: - location.access_rule = lambda state, items_needed=lock.items_needed: ( - has_perk_count(state, player, items_needed//2) - ) - elif victory_condition == VictoryCondition.option_pure_ending: - location.access_rule = lambda state, items_needed=lock.items_needed: ( - has_perk_count(state, player, items_needed//2) and - has_orb_count(state, player, items_needed) - ) - elif victory_condition == VictoryCondition.option_peaceful_ending: - location.access_rule = lambda state, items_needed=lock.items_needed: ( - has_perk_count(state, player, items_needed//2) and - has_orb_count(state, player, items_needed * 3) - ) - - -def biome_unlock_conditions(multiworld: MultiWorld, player: int): - lukki_entrances = multiworld.get_region("Lukki Lair", player).entrances - magical_entrances = multiworld.get_region("Magical Temple", player).entrances - wizard_entrances = multiworld.get_region("Wizards' Den", player).entrances - for entrance in lukki_entrances: - entrance.access_rule = lambda state: state.has("Melee Immunity Perk", player) and\ - state.has("All-Seeing Eye Perk", player) - for entrance in magical_entrances: - entrance.access_rule = lambda state: state.has("All-Seeing Eye Perk", player) - for entrance in wizard_entrances: - entrance.access_rule = lambda state: state.has("All-Seeing Eye Perk", player) - - -def victory_unlock_conditions(multiworld: MultiWorld, player: int) -> None: - victory_condition = multiworld.victory_condition[player].value - victory_location = multiworld.get_location("Victory", player) - - if victory_condition == VictoryCondition.option_pure_ending: - victory_location.access_rule = lambda state: has_orb_count(state, player, 11) - elif victory_condition == VictoryCondition.option_peaceful_ending: - victory_location.access_rule = lambda state: has_orb_count(state, player, 33) - - -# ---------------- -# Main Function -# ---------------- - - -def create_all_rules(multiworld: MultiWorld, player: int) -> None: - if multiworld.players > 1: - ban_items_from_shops(multiworld, player) - ban_early_high_tier_wands(multiworld, player) - lock_holy_mountains_into_spheres(multiworld, player) - holy_mountain_unlock_conditions(multiworld, player) - biome_unlock_conditions(multiworld, player) - victory_unlock_conditions(multiworld, player) - - # Prevent the Map perk (used to find Toveri) from being on Toveri (boss) - if multiworld.bosses_as_checks[player].value >= BossesAsChecks.option_all_bosses: - forbid_items_at_location(multiworld, "Toveri", {"Spatial Awareness Perk"}, player) diff --git a/worlds/noita/__init__.py b/worlds/noita/__init__.py index 792b90e3f55..b8f8e4ae834 100644 --- a/worlds/noita/__init__.py +++ b/worlds/noita/__init__.py @@ -1,6 +1,8 @@ from BaseClasses import Item, Tutorial from worlds.AutoWorld import WebWorld, World -from . import Events, Items, Locations, Options, Regions, Rules +from typing import Dict, Any +from . import events, items, locations, regions, rules +from .options import NoitaOptions class NoitaWeb(WebWorld): @@ -24,13 +26,14 @@ class NoitaWorld(World): """ game = "Noita" - option_definitions = Options.noita_options + options: NoitaOptions + options_dataclass = NoitaOptions - item_name_to_id = Items.item_name_to_id - location_name_to_id = Locations.location_name_to_id + item_name_to_id = items.item_name_to_id + location_name_to_id = locations.location_name_to_id - item_name_groups = Items.item_name_groups - location_name_groups = Locations.location_name_groups + item_name_groups = items.item_name_groups + location_name_groups = locations.location_name_groups data_version = 2 web = NoitaWeb() @@ -40,21 +43,21 @@ def generate_early(self): raise Exception("Noita yaml's slot name has invalid character(s).") # Returned items will be sent over to the client - def fill_slot_data(self): - return {name: getattr(self.multiworld, name)[self.player].value for name in self.option_definitions} + def fill_slot_data(self) -> Dict[str, Any]: + return self.options.as_dict("death_link", "victory_condition", "path_option", "hidden_chests", + "pedestal_checks", "orbs_as_checks", "bosses_as_checks", "extra_orbs", "shop_price") def create_regions(self) -> None: - Regions.create_all_regions_and_connections(self.multiworld, self.player) - Events.create_all_events(self.multiworld, self.player) + regions.create_all_regions_and_connections(self) def create_item(self, name: str) -> Item: - return Items.create_item(self.player, name) + return items.create_item(self.player, name) def create_items(self) -> None: - Items.create_all_items(self.multiworld, self.player) + items.create_all_items(self) def set_rules(self) -> None: - Rules.create_all_rules(self.multiworld, self.player) + rules.create_all_rules(self) def get_filler_item_name(self) -> str: - return self.multiworld.random.choice(Items.filler_items) + return self.random.choice(items.filler_items) diff --git a/worlds/noita/events.py b/worlds/noita/events.py new file mode 100644 index 00000000000..4ec04e98b45 --- /dev/null +++ b/worlds/noita/events.py @@ -0,0 +1,43 @@ +from typing import Dict, TYPE_CHECKING +from BaseClasses import Item, ItemClassification, Location, Region +from . import items, locations + +if TYPE_CHECKING: + from . import NoitaWorld + + +def create_event(player: int, name: str) -> Item: + return items.NoitaItem(name, ItemClassification.progression, None, player) + + +def create_location(player: int, name: str, region: Region) -> Location: + return locations.NoitaLocation(player, name, None, region) + + +def create_locked_location_event(player: int, region: Region, item: str) -> Location: + new_location = create_location(player, item, region) + new_location.place_locked_item(create_event(player, item)) + + region.locations.append(new_location) + return new_location + + +def create_all_events(world: "NoitaWorld", created_regions: Dict[str, Region]) -> None: + for region_name, event in event_locks.items(): + region = created_regions[region_name] + create_locked_location_event(world.player, region, event) + + world.multiworld.completion_condition[world.player] = lambda state: state.has("Victory", world.player) + + +# Maps region names to event names +event_locks: Dict[str, str] = { + "The Work": "Victory", + "Mines": "Portal to Holy Mountain 1", + "Coal Pits": "Portal to Holy Mountain 2", + "Snowy Depths": "Portal to Holy Mountain 3", + "Hiisi Base": "Portal to Holy Mountain 4", + "Underground Jungle": "Portal to Holy Mountain 5", + "The Vault": "Portal to Holy Mountain 6", + "Temple of the Art": "Portal to Holy Mountain 7", +} diff --git a/worlds/noita/Items.py b/worlds/noita/items.py similarity index 82% rename from worlds/noita/Items.py rename to worlds/noita/items.py index c859a803949..6b662fbee69 100644 --- a/worlds/noita/Items.py +++ b/worlds/noita/items.py @@ -1,9 +1,14 @@ import itertools from collections import Counter -from typing import Dict, List, NamedTuple, Set +from typing import Dict, List, NamedTuple, Set, TYPE_CHECKING -from BaseClasses import Item, ItemClassification, MultiWorld -from .Options import BossesAsChecks, VictoryCondition, ExtraOrbs +from BaseClasses import Item, ItemClassification +from .options import BossesAsChecks, VictoryCondition, ExtraOrbs + +if TYPE_CHECKING: + from . import NoitaWorld +else: + NoitaWorld = object class ItemData(NamedTuple): @@ -44,39 +49,40 @@ def create_kantele(victory_condition: VictoryCondition) -> List[str]: return ["Kantele"] if victory_condition.value >= VictoryCondition.option_pure_ending else [] -def create_random_items(multiworld: MultiWorld, player: int, weights: Dict[str, int], count: int) -> List[str]: +def create_random_items(world: NoitaWorld, weights: Dict[str, int], count: int) -> List[str]: filler_pool = weights.copy() - if multiworld.bad_effects[player].value == 0: + if not world.options.bad_effects: del filler_pool["Trap"] - return multiworld.random.choices(population=list(filler_pool.keys()), - weights=list(filler_pool.values()), - k=count) + return world.random.choices(population=list(filler_pool.keys()), + weights=list(filler_pool.values()), + k=count) -def create_all_items(multiworld: MultiWorld, player: int) -> None: - locations_to_fill = len(multiworld.get_unfilled_locations(player)) +def create_all_items(world: NoitaWorld) -> None: + player = world.player + locations_to_fill = len(world.multiworld.get_unfilled_locations(player)) itempool = ( create_fixed_item_pool() - + create_orb_items(multiworld.victory_condition[player], multiworld.extra_orbs[player]) - + create_spatial_awareness_item(multiworld.bosses_as_checks[player]) - + create_kantele(multiworld.victory_condition[player]) + + create_orb_items(world.options.victory_condition, world.options.extra_orbs) + + create_spatial_awareness_item(world.options.bosses_as_checks) + + create_kantele(world.options.victory_condition) ) # if there's not enough shop-allowed items in the pool, we can encounter gen issues # 39 is the number of shop-valid items we need to guarantee if len(itempool) < 39: - itempool += create_random_items(multiworld, player, shop_only_filler_weights, 39 - len(itempool)) + itempool += create_random_items(world, shop_only_filler_weights, 39 - len(itempool)) # this is so that it passes tests and gens if you have minimal locations and only one player - if multiworld.players == 1: - for location in multiworld.get_unfilled_locations(player): + if world.multiworld.players == 1: + for location in world.multiworld.get_unfilled_locations(player): if "Shop Item" in location.name: location.item = create_item(player, itempool.pop()) - locations_to_fill = len(multiworld.get_unfilled_locations(player)) + locations_to_fill = len(world.multiworld.get_unfilled_locations(player)) - itempool += create_random_items(multiworld, player, filler_weights, locations_to_fill - len(itempool)) - multiworld.itempool += [create_item(player, name) for name in itempool] + itempool += create_random_items(world, filler_weights, locations_to_fill - len(itempool)) + world.multiworld.itempool += [create_item(player, name) for name in itempool] # 110000 - 110032 diff --git a/worlds/noita/Locations.py b/worlds/noita/locations.py similarity index 94% rename from worlds/noita/Locations.py rename to worlds/noita/locations.py index 7c27d699ccb..afe16c54e4b 100644 --- a/worlds/noita/Locations.py +++ b/worlds/noita/locations.py @@ -201,11 +201,10 @@ class LocationFlag(IntEnum): } -# Iterating the hidden chest and pedestal locations here to avoid clutter above -def generate_location_entries(locname: str, locinfo: LocationData) -> Dict[str, int]: - if locinfo.ltype in ["chest", "pedestal"]: - return {f"{locname} {i + 1}": locinfo.id + i for i in range(20)} - return {locname: locinfo.id} +def make_location_range(location_name: str, base_id: int, amt: int) -> Dict[str, int]: + if amt == 1: + return {location_name: base_id} + return {f"{location_name} {i+1}": base_id + i for i in range(amt)} location_name_groups: Dict[str, Set[str]] = {"shop": set(), "orb": set(), "boss": set(), "chest": set(), @@ -215,9 +214,11 @@ def generate_location_entries(locname: str, locinfo: LocationData) -> Dict[str, for location_group in location_region_mapping.values(): for locname, locinfo in location_group.items(): - location_name_to_id.update(generate_location_entries(locname, locinfo)) - if locinfo.ltype in ["chest", "pedestal"]: - for i in range(20): - location_name_groups[locinfo.ltype].add(f"{locname} {i + 1}") - else: - location_name_groups[locinfo.ltype].add(locname) + # Iterating the hidden chest and pedestal locations here to avoid clutter above + amount = 20 if locinfo.ltype in ["chest", "pedestal"] else 1 + entries = make_location_range(locname, locinfo.id, amount) + + location_name_to_id.update(entries) + location_name_groups[locinfo.ltype].update(entries.keys()) + +shop_locations = {name for name in location_name_to_id.keys() if "Shop Item" in name} diff --git a/worlds/noita/Options.py b/worlds/noita/options.py similarity index 87% rename from worlds/noita/Options.py rename to worlds/noita/options.py index 0b54597f364..7d987571a58 100644 --- a/worlds/noita/Options.py +++ b/worlds/noita/options.py @@ -1,5 +1,5 @@ -from typing import Dict -from Options import AssembleOptions, Choice, DeathLink, DefaultOnToggle, Range, StartInventoryPool +from Options import Choice, DeathLink, DefaultOnToggle, Range, StartInventoryPool, PerGameCommonOptions +from dataclasses import dataclass class PathOption(Choice): @@ -99,16 +99,16 @@ class ShopPrice(Choice): default = 100 -noita_options: Dict[str, AssembleOptions] = { - "start_inventory_from_pool": StartInventoryPool, - "death_link": DeathLink, - "bad_effects": Traps, - "victory_condition": VictoryCondition, - "path_option": PathOption, - "hidden_chests": HiddenChests, - "pedestal_checks": PedestalChecks, - "orbs_as_checks": OrbsAsChecks, - "bosses_as_checks": BossesAsChecks, - "extra_orbs": ExtraOrbs, - "shop_price": ShopPrice, -} +@dataclass +class NoitaOptions(PerGameCommonOptions): + start_inventory_from_pool: StartInventoryPool + death_link: DeathLink + bad_effects: Traps + victory_condition: VictoryCondition + path_option: PathOption + hidden_chests: HiddenChests + pedestal_checks: PedestalChecks + orbs_as_checks: OrbsAsChecks + bosses_as_checks: BossesAsChecks + extra_orbs: ExtraOrbs + shop_price: ShopPrice diff --git a/worlds/noita/Regions.py b/worlds/noita/regions.py similarity index 61% rename from worlds/noita/Regions.py rename to worlds/noita/regions.py index 561d483b486..6a9c8677238 100644 --- a/worlds/noita/Regions.py +++ b/worlds/noita/regions.py @@ -1,48 +1,43 @@ # Regions are areas in your game that you travel to. -from typing import Dict, Set, List +from typing import Dict, List, TYPE_CHECKING -from BaseClasses import Entrance, MultiWorld, Region -from . import Locations +from BaseClasses import Entrance, Region +from . import locations +from .events import create_all_events +if TYPE_CHECKING: + from . import NoitaWorld -def add_location(player: int, loc_name: str, id: int, region: Region) -> None: - location = Locations.NoitaLocation(player, loc_name, id, region) - region.locations.append(location) - -def add_locations(multiworld: MultiWorld, player: int, region: Region) -> None: - locations = Locations.location_region_mapping.get(region.name, {}) - for location_name, location_data in locations.items(): +def create_locations(world: "NoitaWorld", region: Region) -> None: + locs = locations.location_region_mapping.get(region.name, {}) + for location_name, location_data in locs.items(): location_type = location_data.ltype flag = location_data.flag - opt_orbs = multiworld.orbs_as_checks[player].value - opt_bosses = multiworld.bosses_as_checks[player].value - opt_paths = multiworld.path_option[player].value - opt_num_chests = multiworld.hidden_chests[player].value - opt_num_pedestals = multiworld.pedestal_checks[player].value + is_orb_allowed = location_type == "orb" and flag <= world.options.orbs_as_checks + is_boss_allowed = location_type == "boss" and flag <= world.options.bosses_as_checks + amount = 0 + if flag == locations.LocationFlag.none or is_orb_allowed or is_boss_allowed: + amount = 1 + elif location_type == "chest" and flag <= world.options.path_option: + amount = world.options.hidden_chests.value + elif location_type == "pedestal" and flag <= world.options.path_option: + amount = world.options.pedestal_checks.value - is_orb_allowed = location_type == "orb" and flag <= opt_orbs - is_boss_allowed = location_type == "boss" and flag <= opt_bosses - if flag == Locations.LocationFlag.none or is_orb_allowed or is_boss_allowed: - add_location(player, location_name, location_data.id, region) - elif location_type == "chest" and flag <= opt_paths: - for i in range(opt_num_chests): - add_location(player, f"{location_name} {i+1}", location_data.id + i, region) - elif location_type == "pedestal" and flag <= opt_paths: - for i in range(opt_num_pedestals): - add_location(player, f"{location_name} {i+1}", location_data.id + i, region) + region.add_locations(locations.make_location_range(location_name, location_data.id, amount), + locations.NoitaLocation) # Creates a new Region with the locations found in `location_region_mapping` and adds them to the world. -def create_region(multiworld: MultiWorld, player: int, region_name: str) -> Region: - new_region = Region(region_name, player, multiworld) - add_locations(multiworld, player, new_region) +def create_region(world: "NoitaWorld", region_name: str) -> Region: + new_region = Region(region_name, world.player, world.multiworld) + create_locations(world, new_region) return new_region -def create_regions(multiworld: MultiWorld, player: int) -> Dict[str, Region]: - return {name: create_region(multiworld, player, name) for name in noita_regions} +def create_regions(world: "NoitaWorld") -> Dict[str, Region]: + return {name: create_region(world, name) for name in noita_regions} # An "Entrance" is really just a connection between two regions @@ -60,11 +55,12 @@ def create_connections(player: int, regions: Dict[str, Region]) -> None: # Creates all regions and connections. Called from NoitaWorld. -def create_all_regions_and_connections(multiworld: MultiWorld, player: int) -> None: - created_regions = create_regions(multiworld, player) - create_connections(player, created_regions) +def create_all_regions_and_connections(world: "NoitaWorld") -> None: + created_regions = create_regions(world) + create_connections(world.player, created_regions) + create_all_events(world, created_regions) - multiworld.regions += created_regions.values() + world.multiworld.regions += created_regions.values() # Oh, what a tangled web we weave diff --git a/worlds/noita/rules.py b/worlds/noita/rules.py new file mode 100644 index 00000000000..95039bee463 --- /dev/null +++ b/worlds/noita/rules.py @@ -0,0 +1,172 @@ +from typing import List, NamedTuple, Set, TYPE_CHECKING + +from BaseClasses import CollectionState +from . import items, locations +from .options import BossesAsChecks, VictoryCondition +from worlds.generic import Rules as GenericRules + +if TYPE_CHECKING: + from . import NoitaWorld + + +class EntranceLock(NamedTuple): + source: str + destination: str + event: str + items_needed: int + + +entrance_locks: List[EntranceLock] = [ + EntranceLock("Mines", "Coal Pits Holy Mountain", "Portal to Holy Mountain 1", 1), + EntranceLock("Coal Pits", "Snowy Depths Holy Mountain", "Portal to Holy Mountain 2", 2), + EntranceLock("Snowy Depths", "Hiisi Base Holy Mountain", "Portal to Holy Mountain 3", 3), + EntranceLock("Hiisi Base", "Underground Jungle Holy Mountain", "Portal to Holy Mountain 4", 4), + EntranceLock("Underground Jungle", "Vault Holy Mountain", "Portal to Holy Mountain 5", 5), + EntranceLock("The Vault", "Temple of the Art Holy Mountain", "Portal to Holy Mountain 6", 6), + EntranceLock("Temple of the Art", "Laboratory Holy Mountain", "Portal to Holy Mountain 7", 7), +] + + +holy_mountain_regions: List[str] = [ + "Coal Pits Holy Mountain", + "Snowy Depths Holy Mountain", + "Hiisi Base Holy Mountain", + "Underground Jungle Holy Mountain", + "Vault Holy Mountain", + "Temple of the Art Holy Mountain", + "Laboratory Holy Mountain", +] + + +wand_tiers: List[str] = [ + "Wand (Tier 1)", # Coal Pits + "Wand (Tier 2)", # Snowy Depths + "Wand (Tier 3)", # Hiisi Base + "Wand (Tier 4)", # Underground Jungle + "Wand (Tier 5)", # The Vault + "Wand (Tier 6)", # Temple of the Art +] + + +items_hidden_from_shops: Set[str] = {"Gold (200)", "Gold (1000)", "Potion", "Random Potion", "Secret Potion", + "Chaos Die", "Greed Die", "Kammi", "Refreshing Gourd", "Sädekivi", "Broken Wand", + "Powder Pouch"} + +perk_list: List[str] = list(filter(items.item_is_perk, items.item_table.keys())) + + +# ---------------- +# Helper Functions +# ---------------- + + +def has_perk_count(state: CollectionState, player: int, amount: int) -> bool: + return sum(state.count(perk, player) for perk in perk_list) >= amount + + +def has_orb_count(state: CollectionState, player: int, amount: int) -> bool: + return state.count("Orb", player) >= amount + + +def forbid_items_at_locations(world: "NoitaWorld", shop_locations: Set[str], forbidden_items: Set[str]): + for shop_location in shop_locations: + location = world.multiworld.get_location(shop_location, world.player) + GenericRules.forbid_items_for_player(location, forbidden_items, world.player) + + +# ---------------- +# Rule Functions +# ---------------- + + +# Prevent gold and potions from appearing as purchasable items in shops (because physics will destroy them) +# def ban_items_from_shops(world: "NoitaWorld") -> None: +# for location_name in Locations.location_name_to_id.keys(): +# if "Shop Item" in location_name: +# forbid_items_at_location(world, location_name, items_hidden_from_shops) +def ban_items_from_shops(world: "NoitaWorld") -> None: + forbid_items_at_locations(world, locations.shop_locations, items_hidden_from_shops) + + +# Prevent high tier wands from appearing in early Holy Mountain shops +def ban_early_high_tier_wands(world: "NoitaWorld") -> None: + for i, region_name in enumerate(holy_mountain_regions): + wands_to_forbid = set(wand_tiers[i+1:]) + + locations_in_region = set(locations.location_region_mapping[region_name].keys()) + forbid_items_at_locations(world, locations_in_region, wands_to_forbid) + + # Prevent high tier wands from appearing in the Secret shop + wands_to_forbid = set(wand_tiers[3:]) + locations_in_region = set(locations.location_region_mapping["Secret Shop"].keys()) + forbid_items_at_locations(world, locations_in_region, wands_to_forbid) + + +def lock_holy_mountains_into_spheres(world: "NoitaWorld") -> None: + for lock in entrance_locks: + location = world.multiworld.get_entrance(f"From {lock.source} To {lock.destination}", world.player) + GenericRules.set_rule(location, lambda state, evt=lock.event: state.has(evt, world.player)) + + +def holy_mountain_unlock_conditions(world: "NoitaWorld") -> None: + victory_condition = world.options.victory_condition.value + for lock in entrance_locks: + location = world.multiworld.get_location(lock.event, world.player) + + if victory_condition == VictoryCondition.option_greed_ending: + location.access_rule = lambda state, items_needed=lock.items_needed: ( + has_perk_count(state, world.player, items_needed//2) + ) + elif victory_condition == VictoryCondition.option_pure_ending: + location.access_rule = lambda state, items_needed=lock.items_needed: ( + has_perk_count(state, world.player, items_needed//2) and + has_orb_count(state, world.player, items_needed) + ) + elif victory_condition == VictoryCondition.option_peaceful_ending: + location.access_rule = lambda state, items_needed=lock.items_needed: ( + has_perk_count(state, world.player, items_needed//2) and + has_orb_count(state, world.player, items_needed * 3) + ) + + +def biome_unlock_conditions(world: "NoitaWorld"): + lukki_entrances = world.multiworld.get_region("Lukki Lair", world.player).entrances + magical_entrances = world.multiworld.get_region("Magical Temple", world.player).entrances + wizard_entrances = world.multiworld.get_region("Wizards' Den", world.player).entrances + for entrance in lukki_entrances: + entrance.access_rule = lambda state: state.has("Melee Immunity Perk", world.player) and\ + state.has("All-Seeing Eye Perk", world.player) + for entrance in magical_entrances: + entrance.access_rule = lambda state: state.has("All-Seeing Eye Perk", world.player) + for entrance in wizard_entrances: + entrance.access_rule = lambda state: state.has("All-Seeing Eye Perk", world.player) + + +def victory_unlock_conditions(world: "NoitaWorld") -> None: + victory_condition = world.options.victory_condition.value + victory_location = world.multiworld.get_location("Victory", world.player) + + if victory_condition == VictoryCondition.option_pure_ending: + victory_location.access_rule = lambda state: has_orb_count(state, world.player, 11) + elif victory_condition == VictoryCondition.option_peaceful_ending: + victory_location.access_rule = lambda state: has_orb_count(state, world.player, 33) + + +# ---------------- +# Main Function +# ---------------- + + +def create_all_rules(world: "NoitaWorld") -> None: + if world.multiworld.players > 1: + ban_items_from_shops(world) + ban_early_high_tier_wands(world) + lock_holy_mountains_into_spheres(world) + holy_mountain_unlock_conditions(world) + biome_unlock_conditions(world) + victory_unlock_conditions(world) + + # Prevent the Map perk (used to find Toveri) from being on Toveri (boss) + if world.options.bosses_as_checks.value >= BossesAsChecks.option_all_bosses: + toveri = world.multiworld.get_location("Toveri", world.player) + GenericRules.forbid_items_for_player(toveri, {"Spatial Awareness Perk"}, world.player) From aa72f671bc7a0690200476b0630afd9e06279207 Mon Sep 17 00:00:00 2001 From: black-sliver <59490463+black-sliver@users.noreply.github.com> Date: Sun, 21 Jan 2024 19:34:24 +0100 Subject: [PATCH 07/38] SoE: fix naming of atlas medallion (#2747) In pyevermizer, it's called Atlas Medallion, not Amulet, leading to an empty group and to code not considering them as an alchemy ingredient when swapping out for a trap or an energy core fragment. Also adds a test. --- worlds/soe/__init__.py | 2 +- worlds/soe/test/test_item_mapping.py | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 worlds/soe/test/test_item_mapping.py diff --git a/worlds/soe/__init__.py b/worlds/soe/__init__.py index 74387fb1be8..bbe018da532 100644 --- a/worlds/soe/__init__.py +++ b/worlds/soe/__init__.py @@ -81,7 +81,7 @@ # item helpers _ingredients = ( 'Wax', 'Water', 'Vinegar', 'Root', 'Oil', 'Mushroom', 'Mud Pepper', 'Meteorite', 'Limestone', 'Iron', - 'Gunpowder', 'Grease', 'Feather', 'Ethanol', 'Dry Ice', 'Crystal', 'Clay', 'Brimstone', 'Bone', 'Atlas Amulet', + 'Gunpowder', 'Grease', 'Feather', 'Ethanol', 'Dry Ice', 'Crystal', 'Clay', 'Brimstone', 'Bone', 'Atlas Medallion', 'Ash', 'Acorn' ) _other_items = ( diff --git a/worlds/soe/test/test_item_mapping.py b/worlds/soe/test/test_item_mapping.py new file mode 100644 index 00000000000..7df05837c78 --- /dev/null +++ b/worlds/soe/test/test_item_mapping.py @@ -0,0 +1,21 @@ +from unittest import TestCase +from .. import SoEWorld + + +class TestMapping(TestCase): + def test_atlas_medallion_name_group(self) -> None: + """ + Test that we used the pyevermizer name for Atlas Medallion (not Amulet) in item groups. + """ + self.assertIn("Any Atlas Medallion", SoEWorld.item_name_groups) + + def test_atlas_medallion_name_items(self) -> None: + """ + Test that we used the pyevermizer name for Atlas Medallion (not Amulet) in items. + """ + found_medallion = False + for name in SoEWorld.item_name_to_id: + self.assertNotIn("Atlas Amulet", name, "Expected Atlas Medallion, not Amulet") + if "Atlas Medallion" in name: + found_medallion = True + self.assertTrue(found_medallion, "Did not find Atlas Medallion in items") From b4212d1c3eaef5980fae7329796636274d80df1a Mon Sep 17 00:00:00 2001 From: Scipio Wright Date: Sun, 28 Jan 2024 15:13:03 -0500 Subject: [PATCH 08/38] TUNIC: Fix for nmg logic bug (#2772) --- worlds/tunic/er_data.py | 51 ++++++++++++++++++++++---------------- worlds/tunic/er_rules.py | 19 +++++++++++--- worlds/tunic/er_scripts.py | 6 ++--- 3 files changed, 48 insertions(+), 28 deletions(-) diff --git a/worlds/tunic/er_data.py b/worlds/tunic/er_data.py index 2d3bcc025f4..95d33d4aff6 100644 --- a/worlds/tunic/er_data.py +++ b/worlds/tunic/er_data.py @@ -37,7 +37,7 @@ def scene_destination(self) -> str: # full, nonchanging name to interpret by th destination="Furnace_gyro_lower"), Portal(name="Caustic Light Cave Entrance", region="Overworld", destination="Overworld Cave_"), - Portal(name="Swamp Upper Entrance", region="Overworld Laurels", + Portal(name="Swamp Upper Entrance", region="Overworld Swamp Upper Entry", destination="Swamp Redux 2_wall"), Portal(name="Swamp Lower Entrance", region="Overworld", destination="Swamp Redux 2_conduit"), @@ -49,7 +49,7 @@ def scene_destination(self) -> str: # full, nonchanging name to interpret by th destination="Atoll Redux_upper"), Portal(name="Atoll Lower Entrance", region="Overworld", destination="Atoll Redux_lower"), - Portal(name="Special Shop Entrance", region="Overworld Laurels", + Portal(name="Special Shop Entrance", region="Overworld Special Shop Entry", destination="ShopSpecial_"), Portal(name="Maze Cave Entrance", region="Overworld", destination="Maze Room_"), @@ -57,7 +57,7 @@ def scene_destination(self) -> str: # full, nonchanging name to interpret by th destination="Archipelagos Redux_upper"), Portal(name="West Garden Entrance from Furnace", region="Overworld to West Garden from Furnace", destination="Archipelagos Redux_lower"), - Portal(name="West Garden Laurels Entrance", region="Overworld Laurels", + Portal(name="West Garden Laurels Entrance", region="Overworld West Garden Laurels Entry", destination="Archipelagos Redux_lowest"), Portal(name="Temple Door Entrance", region="Overworld Temple Door", destination="Temple_main"), @@ -533,7 +533,9 @@ class Hint(IntEnum): "Overworld": RegionInfo("Overworld Redux"), "Overworld Holy Cross": RegionInfo("Fake", dead_end=DeadEnd.all_cats), "Overworld Belltower": RegionInfo("Overworld Redux"), # the area with the belltower and chest - "Overworld Laurels": RegionInfo("Overworld Redux"), # all spots in Overworld that you need laurels to reach + "Overworld Swamp Upper Entry": RegionInfo("Overworld Redux"), # upper swamp entry spot + "Overworld Special Shop Entry": RegionInfo("Overworld Redux"), # special shop entry spot + "Overworld West Garden Laurels Entry": RegionInfo("Overworld Redux"), # west garden laurels entry "Overworld to West Garden from Furnace": RegionInfo("Overworld Redux", hint=Hint.region), "Overworld Well to Furnace Rail": RegionInfo("Overworld Redux"), # the tiny rail passageway "Overworld Ruined Passage Door": RegionInfo("Overworld Redux"), # the small space betweeen the door and the portal @@ -710,7 +712,7 @@ class Hint(IntEnum): hallway_helper[p2] = p1 # so we can just loop over this instead of doing some complicated thing to deal with hallways in the hints -hallways_nmg: Dict[str, str] = { +hallways_ur: Dict[str, str] = { "Ruins Passage, Overworld Redux_east": "Ruins Passage, Overworld Redux_west", "East Forest Redux Interior, East Forest Redux_upper": "East Forest Redux Interior, East Forest Redux_lower", "Forest Boss Room, East Forest Redux Laddercave_": "Forest Boss Room, Forest Belltower_", @@ -720,20 +722,22 @@ class Hint(IntEnum): "ziggurat2020_0, Quarry Redux_": "ziggurat2020_0, ziggurat2020_1_", "Purgatory, Purgatory_bottom": "Purgatory, Purgatory_top", } -hallway_helper_nmg: Dict[str, str] = {} -for p1, p2 in hallways.items(): - hallway_helper[p1] = p2 - hallway_helper[p2] = p1 +hallway_helper_ur: Dict[str, str] = {} +for p1, p2 in hallways_ur.items(): + hallway_helper_ur[p1] = p2 + hallway_helper_ur[p2] = p1 # the key is the region you have, the value is the regions you get for having that region # this is mostly so we don't have to do something overly complex to get this information dependent_regions: Dict[Tuple[str, ...], List[str]] = { - ("Overworld", "Overworld Belltower", "Overworld Laurels", "Overworld Southeast Cross Door", "Overworld Temple Door", + ("Overworld", "Overworld Belltower", "Overworld Swamp Upper Entry", "Overworld Special Shop Entry", + "Overworld West Garden Laurels Entry", "Overworld Southeast Cross Door", "Overworld Temple Door", "Overworld Fountain Cross Door", "Overworld Town Portal", "Overworld Spawn Portal"): - ["Overworld", "Overworld Belltower", "Overworld Laurels", "Overworld Ruined Passage Door", - "Overworld Southeast Cross Door", "Overworld Old House Door", "Overworld Temple Door", - "Overworld Fountain Cross Door", "Overworld Town Portal", "Overworld Spawn Portal"], + ["Overworld", "Overworld Belltower", "Overworld Swamp Upper Entry", "Overworld Special Shop Entry", + "Overworld West Garden Laurels Entry", "Overworld Ruined Passage Door", "Overworld Southeast Cross Door", + "Overworld Old House Door", "Overworld Temple Door", "Overworld Fountain Cross Door", "Overworld Town Portal", + "Overworld Spawn Portal"], ("Old House Front",): ["Old House Front", "Old House Back"], ("Furnace Fuse", "Furnace Ladder Area", "Furnace Walking Path"): @@ -818,12 +822,14 @@ class Hint(IntEnum): dependent_regions_nmg: Dict[Tuple[str, ...], List[str]] = { - ("Overworld", "Overworld Belltower", "Overworld Laurels", "Overworld Southeast Cross Door", "Overworld Temple Door", + ("Overworld", "Overworld Belltower", "Overworld Swamp Upper Entry", "Overworld Special Shop Entry", + "Overworld West Garden Laurels Entry", "Overworld Southeast Cross Door", "Overworld Temple Door", "Overworld Fountain Cross Door", "Overworld Town Portal", "Overworld Spawn Portal", "Overworld Ruined Passage Door"): - ["Overworld", "Overworld Belltower", "Overworld Laurels", "Overworld Ruined Passage Door", - "Overworld Southeast Cross Door", "Overworld Old House Door", "Overworld Temple Door", - "Overworld Fountain Cross Door", "Overworld Town Portal", "Overworld Spawn Portal"], + ["Overworld", "Overworld Belltower", "Overworld Swamp Upper Entry", "Overworld Special Shop Entry", + "Overworld West Garden Laurels Entry", "Overworld Ruined Passage Door", "Overworld Southeast Cross Door", + "Overworld Old House Door", "Overworld Temple Door", "Overworld Fountain Cross Door", "Overworld Town Portal", + "Overworld Spawn Portal"], # can laurels through the gate ("Old House Front", "Old House Back"): ["Old House Front", "Old House Back"], @@ -908,13 +914,14 @@ class Hint(IntEnum): dependent_regions_ur: Dict[Tuple[str, ...], List[str]] = { # can use ladder storage to get to the well rail - ("Overworld", "Overworld Belltower", "Overworld Laurels", "Overworld Southeast Cross Door", "Overworld Temple Door", + ("Overworld", "Overworld Belltower", "Overworld Swamp Upper Entry", "Overworld Special Shop Entry", + "Overworld West Garden Laurels Entry", "Overworld Southeast Cross Door", "Overworld Temple Door", "Overworld Fountain Cross Door", "Overworld Town Portal", "Overworld Spawn Portal", "Overworld Ruined Passage Door"): - ["Overworld", "Overworld Belltower", "Overworld Laurels", "Overworld Ruined Passage Door", - "Overworld Southeast Cross Door", "Overworld Old House Door", "Overworld Temple Door", - "Overworld Fountain Cross Door", "Overworld Town Portal", "Overworld Spawn Portal", - "Overworld Well to Furnace Rail"], + ["Overworld", "Overworld Belltower", "Overworld Swamp Upper Entry", "Overworld Special Shop Entry", + "Overworld West Garden Laurels Entry", "Overworld Ruined Passage Door", "Overworld Southeast Cross Door", + "Overworld Old House Door", "Overworld Temple Door", "Overworld Fountain Cross Door", "Overworld Town Portal", + "Overworld Spawn Portal", "Overworld Well to Furnace Rail"], # can laurels through the gate ("Old House Front", "Old House Back"): ["Old House Front", "Old House Back"], diff --git a/worlds/tunic/er_rules.py b/worlds/tunic/er_rules.py index 5d88022dc15..ab0cf02bd97 100644 --- a/worlds/tunic/er_rules.py +++ b/worlds/tunic/er_rules.py @@ -53,9 +53,23 @@ def set_er_region_rules(world: "TunicWorld", ability_unlocks: Dict[str, int], re or (state.has(laurels, player) and options.logic_rules)) regions["Overworld"].connect( - connecting_region=regions["Overworld Laurels"], + connecting_region=regions["Overworld Swamp Upper Entry"], rule=lambda state: state.has(laurels, player)) - regions["Overworld Laurels"].connect( + regions["Overworld Swamp Upper Entry"].connect( + connecting_region=regions["Overworld"], + rule=lambda state: state.has(laurels, player)) + + regions["Overworld"].connect( + connecting_region=regions["Overworld Special Shop Entry"], + rule=lambda state: state.has(laurels, player)) + regions["Overworld Special Shop Entry"].connect( + connecting_region=regions["Overworld"], + rule=lambda state: state.has(laurels, player)) + + regions["Overworld"].connect( + connecting_region=regions["Overworld West Garden Laurels Entry"], + rule=lambda state: state.has(laurels, player)) + regions["Overworld West Garden Laurels Entry"].connect( connecting_region=regions["Overworld"], rule=lambda state: state.has(laurels, player)) @@ -230,7 +244,6 @@ def set_er_region_rules(world: "TunicWorld", ability_unlocks: Dict[str, int], re connecting_region=regions["West Garden Laurels Exit"], rule=lambda state: state.has(laurels, player)) - # todo: can you wake the boss, then grapple to it, then kill it? regions["West Garden after Boss"].connect( connecting_region=regions["West Garden"], rule=lambda state: state.has(laurels, player)) diff --git a/worlds/tunic/er_scripts.py b/worlds/tunic/er_scripts.py index 4d640b2fda7..4e28344b20a 100644 --- a/worlds/tunic/er_scripts.py +++ b/worlds/tunic/er_scripts.py @@ -1,7 +1,7 @@ from typing import Dict, List, Set, Tuple, TYPE_CHECKING from BaseClasses import Region, ItemClassification, Item, Location from .locations import location_table -from .er_data import Portal, tunic_er_regions, portal_mapping, hallway_helper, hallway_helper_nmg, \ +from .er_data import Portal, tunic_er_regions, portal_mapping, hallway_helper, hallway_helper_ur, \ dependent_regions, dependent_regions_nmg, dependent_regions_ur from .er_rules import set_er_region_rules @@ -28,8 +28,8 @@ def hint_helper(portal: Portal, hint_string: str = "") -> str: if hint_string == "": hint_string = portal.name - if logic_rules: - hallways = hallway_helper_nmg + if logic_rules == "unrestricted": + hallways = hallway_helper_ur else: hallways = hallway_helper From 0bc9966d6f4d216948c7db345b54a04ce94f2d73 Mon Sep 17 00:00:00 2001 From: JusticePS <5125765+JusticePS@users.noreply.github.com> Date: Mon, 29 Jan 2024 09:37:55 -0800 Subject: [PATCH 09/38] Adventure: Fix iterable copy error when freeincarnate_max is tuned low (#2774) --- worlds/adventure/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/worlds/adventure/__init__.py b/worlds/adventure/__init__.py index 105725bd053..9b9b0d77d80 100644 --- a/worlds/adventure/__init__.py +++ b/worlds/adventure/__init__.py @@ -271,7 +271,7 @@ def pre_fill(self): overworld_locations_copy = overworld.locations.copy() all_locations = self.multiworld.get_locations(self.player) - locations_copy = all_locations.copy() + locations_copy = list(all_locations) for loc in all_locations: if loc.item is not None or loc.progress_type != LocationProgressType.DEFAULT: locations_copy.remove(loc) From 69c80501c494be4edf8210949fa5fed7e9f73f7c Mon Sep 17 00:00:00 2001 From: JaredWeakStrike <96694163+JaredWeakStrike@users.noreply.github.com> Date: Mon, 29 Jan 2024 12:39:45 -0500 Subject: [PATCH 10/38] KH2: Fix empty location groups (#2757) Co-authored-by: Aaron Wagener --- worlds/kh2/Locations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/worlds/kh2/Locations.py b/worlds/kh2/Locations.py index 61fafe90941..100e971e7df 100644 --- a/worlds/kh2/Locations.py +++ b/worlds/kh2/Locations.py @@ -1356,5 +1356,5 @@ location_groups: typing.Dict[str, list] location_groups = { Region_Name: [loc for loc in Region_Locs if "Event" not in loc] - for Region_Name, Region_Locs in KH2REGIONS.items() if Region_Locs + for Region_Name, Region_Locs in KH2REGIONS.items() if Region_Locs and "Event" not in Region_Locs[0] } From 1b188bab3c3de90bcba374aec2e95f3fbe56999d Mon Sep 17 00:00:00 2001 From: black-sliver <59490463+black-sliver@users.noreply.github.com> Date: Tue, 30 Jan 2024 08:21:23 +0100 Subject: [PATCH 11/38] Doc: add GM libs to network protocol.md (#2744) --- docs/network protocol.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/network protocol.md b/docs/network protocol.md index d10e6519a93..338db55299b 100644 --- a/docs/network protocol.md +++ b/docs/network protocol.md @@ -27,6 +27,8 @@ There are also a number of community-supported libraries available that implemen | Haxe | [hxArchipelago](https://lib.haxe.org/p/hxArchipelago) | | | Rust | [ArchipelagoRS](https://github.com/ryanisaacg/archipelago_rs) | | | Lua | [lua-apclientpp](https://github.com/black-sliver/lua-apclientpp) | | +| Game Maker + Studio 1.x | [gm-apclientpp](https://github.com/black-sliver/gm-apclientpp) | For GM7, GM8 and GMS1.x, maybe older | +| GameMaker: Studio 2.x+ | [see Discord](https://discord.com/channels/731205301247803413/1166418532519653396) | | ## Synchronizing Items When the client receives a [ReceivedItems](#ReceivedItems) packet, if the `index` argument does not match the next index that the client expects then it is expected that the client will re-sync items with the server. This can be accomplished by sending the server a [Sync](#Sync) packet and then a [LocationChecks](#LocationChecks) packet. From 5663c21f3990eea1e3f5f37f975d0a76ac032b00 Mon Sep 17 00:00:00 2001 From: black-sliver <59490463+black-sliver@users.noreply.github.com> Date: Tue, 30 Jan 2024 08:34:54 +0100 Subject: [PATCH 12/38] Tests: test that item/location name groups are not empty (#2748) * Tests: test that item/location name groups are not empty * Tests: better name for test_groups TestCase --------- Co-authored-by: Fabian Dill --- test/general/test_groups.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 test/general/test_groups.py diff --git a/test/general/test_groups.py b/test/general/test_groups.py new file mode 100644 index 00000000000..486d3311fa6 --- /dev/null +++ b/test/general/test_groups.py @@ -0,0 +1,27 @@ +from unittest import TestCase + +from worlds.AutoWorld import AutoWorldRegister + + +class TestNameGroups(TestCase): + def test_item_name_groups_not_empty(self) -> None: + """ + Test that there are no empty item name groups, which is likely a bug. + """ + for game_name, world_type in AutoWorldRegister.world_types.items(): + if not world_type.item_id_to_name: + continue # ignore worlds without items + with self.subTest(game=game_name): + for name, group in world_type.item_name_groups.items(): + self.assertTrue(group, f"Item name group \"{name}\" of \"{game_name}\" is empty") + + def test_location_name_groups_not_empty(self) -> None: + """ + Test that there are no empty location name groups, which is likely a bug. + """ + for game_name, world_type in AutoWorldRegister.world_types.items(): + if not world_type.location_id_to_name: + continue # ignore worlds without locations + with self.subTest(game=game_name): + for name, group in world_type.location_name_groups.items(): + self.assertTrue(group, f"Location name group \"{name}\" of \"{game_name}\" is empty") From dc49d50c2ceb12258c06aaad6bd6f4a0f9267194 Mon Sep 17 00:00:00 2001 From: Benny D <78334662+benny-dreamly@users.noreply.github.com> Date: Tue, 30 Jan 2024 00:58:31 -0700 Subject: [PATCH 13/38] Docs: fixed typo in Stardew Valley setup guide (#2770) * fix typo * fix another typo * Update worlds/stardew_valley/docs/en_Stardew Valley.md Co-authored-by: Nicholas Saylor <79181893+nicholassaylor@users.noreply.github.com> --------- Co-authored-by: Nicholas Saylor <79181893+nicholassaylor@users.noreply.github.com> --- worlds/stardew_valley/docs/en_Stardew Valley.md | 2 +- worlds/stardew_valley/docs/setup_en.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/worlds/stardew_valley/docs/en_Stardew Valley.md b/worlds/stardew_valley/docs/en_Stardew Valley.md index a880a40b971..04ba9c15c3c 100644 --- a/worlds/stardew_valley/docs/en_Stardew Valley.md +++ b/worlds/stardew_valley/docs/en_Stardew Valley.md @@ -124,6 +124,6 @@ List of supported mods: ## Multiplayer -You cannot play an Archipelago Slot in multiplayer at the moment. There is no short-terms plans to support that feature. +You cannot play an Archipelago Slot in multiplayer at the moment. There are no short-term plans to support that feature. You can, however, send Stardew Valley objects as gifts from one Stardew Player to another Stardew player, using in-game Joja Prime delivery, for a fee. This exclusive feature can be turned off if you don't want to send and receive gifts. diff --git a/worlds/stardew_valley/docs/setup_en.md b/worlds/stardew_valley/docs/setup_en.md index 68c7fb9af6a..d8f0e16b101 100644 --- a/worlds/stardew_valley/docs/setup_en.md +++ b/worlds/stardew_valley/docs/setup_en.md @@ -84,4 +84,4 @@ See the [Supported mods documentation](https://github.com/agilbert1412/StardewAr ### Multiplayer -You cannot play an Archipelago Slot in multiplayer at the moment. There are no short-terms plans to support that feature. \ No newline at end of file +You cannot play an Archipelago Slot in multiplayer at the moment. There are no short-term plans to support that feature. From 144769a14183492a5b73ede4c6e0c0109038416e Mon Sep 17 00:00:00 2001 From: Ixrec Date: Tue, 30 Jan 2024 08:00:47 +0000 Subject: [PATCH 14/38] Tests: use strict equality in some tests # (#2778) * Tests: replace .assertLess/GreaterEqual() with .assertEqual() in two tests where strict equality seems more correct --- test/general/test_items.py | 8 ++++---- test/general/test_locations.py | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/test/general/test_items.py b/test/general/test_items.py index bd6c3fd8530..1612937225f 100644 --- a/test/general/test_items.py +++ b/test/general/test_items.py @@ -43,15 +43,15 @@ def test_item_name_group_conflict(self): with self.subTest(group_name, group_name=group_name): self.assertNotIn(group_name, world_type.item_name_to_id) - def test_item_count_greater_equal_locations(self): - """Test that by the pre_fill step under default settings, each game submits items >= locations""" + def test_item_count_equal_locations(self): + """Test that by the pre_fill step under default settings, each game submits items == locations""" for game_name, world_type in AutoWorldRegister.world_types.items(): with self.subTest("Game", game=game_name): multiworld = setup_solo_multiworld(world_type) - self.assertGreaterEqual( + self.assertEqual( len(multiworld.itempool), len(multiworld.get_unfilled_locations()), - f"{game_name} Item count MUST meet or exceed the number of locations", + f"{game_name} Item count MUST match the number of locations", ) def test_items_in_datapackage(self): diff --git a/test/general/test_locations.py b/test/general/test_locations.py index 725b48e62f7..2ac059312c1 100644 --- a/test/general/test_locations.py +++ b/test/general/test_locations.py @@ -11,14 +11,14 @@ def test_create_duplicate_locations(self): multiworld = setup_solo_multiworld(world_type) locations = Counter(location.name for location in multiworld.get_locations()) if locations: - self.assertLessEqual(locations.most_common(1)[0][1], 1, - f"{world_type.game} has duplicate of location name {locations.most_common(1)}") + self.assertEqual(locations.most_common(1)[0][1], 1, + f"{world_type.game} has duplicate of location name {locations.most_common(1)}") locations = Counter(location.address for location in multiworld.get_locations() if type(location.address) is int) if locations: - self.assertLessEqual(locations.most_common(1)[0][1], 1, - f"{world_type.game} has duplicate of location ID {locations.most_common(1)}") + self.assertEqual(locations.most_common(1)[0][1], 1, + f"{world_type.game} has duplicate of location ID {locations.most_common(1)}") def test_locations_in_datapackage(self): """Tests that created locations not filled before fill starts exist in the datapackage.""" From 697deb98b4df3bee4b8bd0c4bdf024c7276e770d Mon Sep 17 00:00:00 2001 From: Alchav <59858495+Alchav@users.noreply.github.com> Date: Tue, 30 Jan 2024 03:06:10 -0500 Subject: [PATCH 15/38] =?UTF-8?q?=20Pok=C3=A9mon=20R/B:=20Fix=20Thunder=20?= =?UTF-8?q?Stone=20item=20groups=20#2740?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- worlds/pokemon_rb/items.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/worlds/pokemon_rb/items.py b/worlds/pokemon_rb/items.py index b584869f41b..24cad13252b 100644 --- a/worlds/pokemon_rb/items.py +++ b/worlds/pokemon_rb/items.py @@ -42,7 +42,7 @@ def __init__(self, item_id, classification, groups): "Repel": ItemData(30, ItemClassification.filler, ["Consumables"]), "Old Amber": ItemData(31, ItemClassification.progression_skip_balancing, ["Unique", "Fossils", "Key Items"]), "Fire Stone": ItemData(32, ItemClassification.progression_skip_balancing, ["Unique", "Evolution Stones", "Key Items"]), - "Thunder Stone": ItemData(33, ItemClassification.progression_skip_balancing, ["Unique", "Evolution Stones" "Key Items"]), + "Thunder Stone": ItemData(33, ItemClassification.progression_skip_balancing, ["Unique", "Evolution Stones", "Key Items"]), "Water Stone": ItemData(34, ItemClassification.progression_skip_balancing, ["Unique", "Evolution Stones", "Key Items"]), "HP Up": ItemData(35, ItemClassification.filler, ["Consumables", "Vitamins"]), "Protein": ItemData(36, ItemClassification.filler, ["Consumables", "Vitamins"]), From 016c1e9bb4e539e80df260d114285a48f7fd6e76 Mon Sep 17 00:00:00 2001 From: Aaron Wagener Date: Tue, 30 Jan 2024 14:42:33 -0600 Subject: [PATCH 16/38] Docs: world api general cleanup/overhaul (#2598) * Docs: world api general cleanup/overhaul * add pep-0287 to style doc * some cleanup, reorganization, and grammar improvements * reorder item and region creation * address review comments * fix indent * linter grammar Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> --------- Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> --- docs/options api.md | 31 +- docs/style.md | 7 +- docs/world api.md | 745 ++++++++++++++++++++------------------------ 3 files changed, 358 insertions(+), 425 deletions(-) diff --git a/docs/options api.md b/docs/options api.md index 48a3f763fa9..bfab0096bba 100644 --- a/docs/options api.md +++ b/docs/options api.md @@ -27,14 +27,15 @@ Choice, and defining `alias_true = option_full`. - All options support `random` as a generic option. `random` chooses from any of the available values for that option, and is reserved by AP. You can set this as your default value, but you cannot define your own `option_random`. -As an example, suppose we want an option that lets the user start their game with a sword in their inventory. Let's -create our option class (with a docstring), give it a `display_name`, and add it to our game's options dataclass: +As an example, suppose we want an option that lets the user start their game with a sword in their inventory, an option +to let the player choose the difficulty, and an option to choose how much health the final boss has. Let's create our +option classes (with a docstring), give them a `display_name`, and add them to our game's options dataclass: ```python # options.py from dataclasses import dataclass -from Options import Toggle, PerGameCommonOptions +from Options import Toggle, Range, Choice, PerGameCommonOptions class StartingSword(Toggle): @@ -42,13 +43,33 @@ class StartingSword(Toggle): display_name = "Start With Sword" +class Difficulty(Choice): + """Sets overall game difficulty.""" + display_name = "Difficulty" + option_easy = 0 + option_normal = 1 + option_hard = 2 + alias_beginner = 0 # same as easy but allows the player to use beginner as an alternative for easy in the result in their options + alias_expert = 2 # same as hard + default = 1 # default to normal + + +class FinalBossHP(Range): + """Sets the HP of the final boss""" + display_name = "Final Boss HP" + range_start = 100 + range_end = 10000 + default = 2000 + + @dataclass class ExampleGameOptions(PerGameCommonOptions): starting_sword: StartingSword + difficulty: Difficulty + final_boss_health: FinalBossHP ``` -This will create a `Toggle` option, internally called `starting_sword`. To then submit this to the multiworld, we add it -to our world's `__init__.py`: +To then submit this to the multiworld, we add it to our world's `__init__.py`: ```python from worlds.AutoWorld import World diff --git a/docs/style.md b/docs/style.md index 4cc8111425e..fbf681f28e9 100644 --- a/docs/style.md +++ b/docs/style.md @@ -6,7 +6,6 @@ * 120 character per line for all source files. * Avoid white space errors like trailing spaces. - ## Python Code * We mostly follow [PEP8](https://peps.python.org/pep-0008/). Read below to see the differences. @@ -18,9 +17,10 @@ * Use type annotations where possible for function signatures and class members. * Use type annotations where appropriate for local variables (e.g. `var: List[int] = []`, or when the type is hard or impossible to deduce.) Clear annotations help developers look up and validate API calls. +* New classes, attributes, and methods in core code should have docstrings that follow + [reST style](https://peps.python.org/pep-0287/). * Worlds that do not follow PEP8 should still have a consistent style across its files to make reading easier. - ## Markdown * We almost follow [Google's styleguide](https://google.github.io/styleguide/docguide/style.html). @@ -30,20 +30,17 @@ * One space between bullet/number and text. * No lazy numbering. - ## HTML * Indent with 2 spaces for new code. * kebab-case for ids and classes. - ## CSS * Indent with 2 spaces for new code. * `{` on the same line as the selector. * No space between selector and `{`. - ## JS * Indent with 2 spaces. diff --git a/docs/world api.md b/docs/world api.md index 0ab06da6560..72a67bca9de 100644 --- a/docs/world api.md +++ b/docs/world api.md @@ -1,95 +1,95 @@ # Archipelago API -This document tries to explain some internals required to implement a game for -Archipelago's generation and server. Once a seed is generated, a client or mod is -required to send and receive items between the game and server. +This document tries to explain some aspects of the Archipelago World API used when implementing the generation logic of +a game. -Client implementation is out of scope of this document. Please refer to an -existing game that provides a similar API to yours. -Refer to the following documents as well: -- [network protocol.md](https://github.com/ArchipelagoMW/Archipelago/blob/main/docs/network%20protocol.md) -- [adding games.md](https://github.com/ArchipelagoMW/Archipelago/blob/main/docs/adding%20games.md) +Client implementation is out of scope of this document. Please refer to an existing game that provides a similar API to +yours, and the following documents: -Archipelago will be abbreviated as "AP" from now on. +* [network protocol.md](https://github.com/ArchipelagoMW/Archipelago/blob/main/docs/network%20protocol.md) +* [adding games.md](https://github.com/ArchipelagoMW/Archipelago/blob/main/docs/adding%20games.md) +Archipelago will be abbreviated as "AP" from now on. ## Language AP worlds are written in python3. -Clients that connect to the server to sync items can be in any language that -allows using WebSockets. - +Clients that connect to the server to sync items can be in any language that allows using WebSockets. ## Coding style -AP follows [style.md](https://github.com/ArchipelagoMW/Archipelago/blob/main/docs/style.md). -When in doubt use an IDE with coding style linter, for example PyCharm Community Edition. - +AP follows a [style guide](https://github.com/ArchipelagoMW/Archipelago/blob/main/docs/style.md). +When in doubt, use an IDE with a code-style linter, for example PyCharm Community Edition. ## Docstrings -Docstrings are strings attached to an object in Python that describe what the -object is supposed to be. Certain docstrings will be picked up and used by AP. -They are assigned by writing a string without any assignment right below a -definition. The string must be a triple-quoted string. +Docstrings are strings attached to an object in Python that describe what the object is supposed to be. Certain +docstrings will be picked up and used by AP. They are assigned by writing a string without any assignment right below a +definition. The string must be a triple-quoted string, and should +follow [reST style](https://peps.python.org/pep-0287/). + Example: + ```python from worlds.AutoWorld import World + + class MyGameWorld(World): - """This is the description of My Game that will be displayed on the AP - website.""" + """This is the description of My Game that will be displayed on the AP website.""" ``` - ## Definitions -This section will cover various classes and objects you can use for your world. -While some of the attributes and methods are mentioned here, not all of them are, -but you can find them in `BaseClasses.py`. +This section covers various classes and objects you can use for your world. While some of the attributes and methods +are mentioned here, not all of them are, but you can find them in +[`BaseClasses.py`](https://github.com/ArchipelagoMW/Archipelago/blob/main/BaseClasses.py). ### World Class -A `World` class is the class with all the specifics of a certain game to be -included. It will be instantiated for each player that rolls a seed for that -game. +A `World` is the class with all the specifics of a certain game that is to be included. A new instance will be created +for each player of the game for any given generated multiworld. ### WebWorld Class -A `WebWorld` class contains specific attributes and methods that can be modified -for your world specifically on the webhost: +A `WebWorld` class contains specific attributes and methods that can be modified for your world specifically on the +webhost: -`settings_page`, which can be changed to a link instead of an AP generated settings page. +* `options_page` can be changed to a link instead of an AP-generated options page. -`theme` to be used for your game specific AP pages. Available themes: +* `theme` to be used for your game-specific AP pages. Available themes: -| dirt | grass (default) | grassFlowers | ice | jungle | ocean | partyTime | stone | -|---|---|---|---|---|---|---|---| -| | | | | | | | | + | dirt | grass (default) | grassFlowers | ice | jungle | ocean | partyTime | stone | + |--------------------------------------------|---------------------------------------------|----------------------------------------------------|-------------------------------------------|----------------------------------------------|---------------------------------------------|-------------------------------------------------|---------------------------------------------| + | | | | | | | | | -`bug_report_page` (optional) can be a link to a bug reporting page, most likely a GitHub issue page, that will be placed by the site to help direct users to report bugs. +* `bug_report_page` (optional) can be a link to a bug reporting page, most likely a GitHub issue page, that will be + placed by the site to help users report bugs. -`tutorials` list of `Tutorial` classes where each class represents a guide to be generated on the webhost. +* `tutorials` list of `Tutorial` classes where each class represents a guide to be generated on the webhost. -`game_info_languages` (optional) List of strings for defining the existing gameinfo pages your game supports. The documents must be -prefixed with the same string as defined here. Default already has 'en'. +* `game_info_languages` (optional) list of strings for defining the existing game info pages your game supports. The + documents must be prefixed with the same string as defined here. Default already has 'en'. -`options_presets` (optional) A `Dict[str, Dict[str, Any]]` where the keys are the names of the presets and the values -are the options to be set for that preset. The options are defined as a `Dict[str, Any]` where the keys are the names of -the options and the values are the values to be set for that option. These presets will be available for users to select from on the game's options page. +* `options_presets` (optional) `Dict[str, Dict[str, Any]]` where the keys are the names of the presets and the values + are the options to be set for that preset. The options are defined as a `Dict[str, Any]` where the keys are the names + of the options and the values are the values to be set for that option. These presets will be available for users to + select from on the game's options page. Note: The values must be a non-aliased value for the option type and can only include the following option types: - - If you have a `Range`/`NamedRange` option, the value should be an `int` between the `range_start` and `range_end` - values. - - If you have a `NamedRange` option, the value can alternatively be a `str` that is one of the +* If you have a `Range`/`NamedRange` option, the value should be an `int` between the `range_start` and `range_end` + values. + * If you have a `NamedRange` option, the value can alternatively be a `str` that is one of the `special_range_names` keys. - - If you have a `Choice` option, the value should be a `str` that is one of the `option_` values. - - If you have a `Toggle`/`DefaultOnToggle` option, the value should be a `bool`. - - `random` is also a valid value for any of these option types. +* If you have a `Choice` option, the value should be a `str` that is one of the `option_` values. +* If you have a `Toggle`/`DefaultOnToggle` option, the value should be a `bool`. +* `random` is also a valid value for any of these option types. -`OptionDict`, `OptionList`, `OptionSet`, `FreeText`, or custom `Option`-derived classes are not supported for presets on the webhost at this time. +`OptionDict`, `OptionList`, `OptionSet`, `FreeText`, or custom `Option`-derived classes are not supported for presets on +the webhost at this time. Here is an example of a defined preset: + ```python # presets.py options_presets = { @@ -114,6 +114,7 @@ options_presets = { } } + # __init__.py class RLWeb(WebWorld): options_presets = options_presets @@ -122,47 +123,55 @@ class RLWeb(WebWorld): ### MultiWorld Object -The `MultiWorld` object references the whole multiworld (all items and locations -for all players) and is accessible through `self.multiworld` inside a `World` object. +The `MultiWorld` object references the whole multiworld (all items and locations for all players) and is accessible +through `self.multiworld` from your `World` object. ### Player -The player is just an integer in AP and is accessible through `self.player` -inside a `World` object. +The player is just an `int` in AP and is accessible through `self.player` from your `World` object. ### Player Options -Players provide customized settings for their World in the form of yamls. -A `dataclass` of valid options definitions has to be provided in `self.options_dataclass`. -(It must be a subclass of `PerGameCommonOptions`.) -Option results are automatically added to the `World` object for easy access. -Those are accessible through `self.options.`, and you can get a dictionary of the option values via -`self.options.as_dict()`, passing the desired options as strings. +Options are provided by the user as part of the generation process, intended to affect how their randomizer experience +should play out. These can control aspects such as what locations should be shuffled, what items are in the itempool, +etc. Players provide the customized options for their World in the form of yamls. + +By convention, options are defined in `options.py` and will be used when parsing the players' yaml files. Each option +has its own class, which inherits from a base option type, a docstring to describe it, and a `display_name` property +shown on the website and in spoiler logs. + +The available options are defined by creating a `dataclass`, which must be a subclass of `PerGameCommonOptions`. It has +defined fields for the option names used in the player yamls and used for options access, with their types matching the +appropriate Option class. By convention, the strings that define your option names should be in `snake_case`. The +`dataclass` is then assigned to your `World` by defining its `options_dataclass`. Option results are then automatically +added to the `World` object for easy access, between `World` creation and `generate_early`. These are accessible through +`self.options.`, and you can get a dictionary with option values +via `self.options.as_dict()`, +passing the desired option names as strings. + +Common option types are `Toggle`, `DefaultOnToggle`, `Choice`, and `Range`. +For more information, see the [options api doc](options%20api.md). ### World Settings -Any AP installation can provide settings for a world, for example a ROM file, accessible through -`self.settings.` or `cls.settings.` (new API) -or `Utils.get_options()["_options"][""]` (deprecated). +Settings are set by the user outside the generation process. They can be used for those settings that may affect +generation or client behavior, but should remain static between generations, such as the path to a ROM file. +These settings are accessible through `self.settings.` or `cls.settings.`. -Users can set those in their `host.yaml` file. Some settings may automatically open a file browser if a file is missing. +Users can set these in their `host.yaml` file. Some settings may automatically open a file browser if a file is missing. -Refer to [settings api.md](https://github.com/ArchipelagoMW/Archipelago/blob/main/docs/settings%20api.md) -for details. +Refer to [settings api.md](https://github.com/ArchipelagoMW/Archipelago/blob/main/docs/settings%20api.md) for details. ### Locations -Locations are places where items can be located in your game. This may be chests -or boss drops for RPG-like games but could also be progress in a research tree. +Locations are places where items can be located in your game. This may be chests or boss drops for RPG-like games, but +could also be progress in a research tree, or even something more abstract like a level up. -Each location has a `name` and an `id` (a.k.a. "code" or "address"), is placed -in a Region, has access rules and a classification. -The name needs to be unique in each game and must not be numeric (has to -contain least 1 letter or symbol). The ID needs to be unique across all games -and is best in the same range as the item IDs. -World-specific IDs are 1 to 253-1, IDs ≤ 0 are global and reserved. +Each location has a `name` and an `address` (hereafter referred to as an `id`), is placed in a Region, has access rules, +and has a classification. The name needs to be unique within each game and must not be numeric (must contain least 1 +letter or symbol). The ID needs to be unique across all games, and is best kept in the same range as the item IDs. -Special locations with ID `None` can hold events. +World-specific IDs must be in the range 1 to 253-1; IDs ≤ 0 are global and reserved. Classification is one of `LocationProgressType.DEFAULT`, `PRIORITY` or `EXCLUDED`. The Fill algorithm will force progression items to be placed at priority locations, giving a higher chance of them being @@ -170,22 +179,21 @@ required, and will prevent progression and useful items from being placed at exc #### Documenting Locations -Worlds can optionally provide a `location_descriptions` map which contains -human-friendly descriptions of locations or location groups. These descriptions -will show up in location-selection options in the Weighted Options page. Extra +Worlds can optionally provide a `location_descriptions` map which contains human-friendly descriptions of locations and +location groups. These descriptions will show up in location-selection options on the Weighted Options page. Extra indentation and single newlines will be collapsed into spaces. ```python -# Locations.py +# locations.py location_descriptions = { "Red Potion #6": "In a secret destructible block under the second stairway", - "L2 Spaceship": """ - The group of all items in the spaceship in Level 2. + "L2 Spaceship": + """ + The group of all items in the spaceship in Level 2. - This doesn't include the item on the spaceship door, since it can be - accessed without the Spaeship Key. - """ + This doesn't include the item on the spaceship door, since it can be accessed without the Spaceship Key. + """ } ``` @@ -193,7 +201,7 @@ location_descriptions = { # __init__.py from worlds.AutoWorld import World -from .Locations import location_descriptions +from .locations import location_descriptions class MyGameWorld(World): @@ -202,47 +210,45 @@ class MyGameWorld(World): ### Items -Items are all things that can "drop" for your game. This may be RPG items like -weapons, could as well be technologies you normally research in a research tree. +Items are all things that can "drop" for your game. This may be RPG items like weapons, or technologies you normally +research in a research tree. -Each item has a `name`, an `id` (can be known as "code"), and a classification. -The most important classification is `progression` (formerly advancement). -Progression items are items which a player may require to progress in -their world. Progression items will be assigned to locations with higher -priority and moved around to meet defined rules and accomplish progression -balancing. +Each item has a `name`, a `code` (hereafter referred to as `id`), and a classification. +The most important classification is `progression`. Progression items are items which a player *may* require to progress +in their world. If an item can possibly be considered for logic (it's referenced in a location's rules) it *must* be +progression. Progression items will be assigned to locations with higher priority, and moved around to meet defined rules +and satisfy progression balancing. -The name needs to be unique in each game, meaning a duplicate item has the -same ID. Name must not be numeric (has to contain at least 1 letter or symbol). +The name needs to be unique within each game, meaning if you need to create multiple items with the same name, they +will all have the same ID. Name must not be numeric (must contain at least 1 letter or symbol). -Special items with ID `None` can mark events (read below). +Other classifications include: -Other classifications include * `filler`: a regular item or trash item -* `useful`: generally quite useful, but not required for anything logical +* `useful`: generally quite useful, but not required for anything logical. Cannot be placed on excluded locations * `trap`: negative impact on the player * `skip_balancing`: denotes that an item should not be moved to an earlier sphere for the purpose of balancing (to be combined with `progression`; see below) * `progression_skip_balancing`: the combination of `progression` and `skip_balancing`, i.e., a progression item that - will not be moved around by progression balancing; used, e.g., for currency or tokens + will not be moved around by progression balancing; used, e.g., for currency or tokens, to not flood early spheres #### Documenting Items -Worlds can optionally provide an `item_descriptions` map which contains -human-friendly descriptions of items or item groups. These descriptions will -show up in item-selection options in the Weighted Options page. Extra -indentation and single newlines will be collapsed into spaces. +Worlds can optionally provide an `item_descriptions` map which contains human-friendly descriptions of items and item +groups. These descriptions will show up in item-selection options on the Weighted Options page. Extra indentation and +single newlines will be collapsed into spaces. ```python -# Items.py +# items.py item_descriptions = { "Red Potion": "A standard health potion", - "Spaceship Key": """ - The key to the spaceship in Level 2. + "Spaceship Key": + """ + The key to the spaceship in Level 2. - This is necessary to get to the Star Realm. - """ + This is necessary to get to the Star Realm. + """ } ``` @@ -250,7 +256,7 @@ item_descriptions = { # __init__.py from worlds.AutoWorld import World -from .Items import item_descriptions +from .items import item_descriptions class MyGameWorld(World): @@ -259,215 +265,128 @@ class MyGameWorld(World): ### Events -Events will mark some progress. You define an event location, an -event item, strap some rules to the location (i.e. hold certain -items) and manually place the event item at the event location. +An Event is a special combination of a Location and an Item, with both having an `id` of `None`. These can be used to +track certain logic interactions, with the Event Item being required for access in other locations or regions, but not +being "real". Since the item and location have no ID, they get dropped at the end of generation and so the server is +never made aware of them and these locations can never be checked, nor can the items be received during play. +They may also be used for making the spoiler log look nicer, i.e. by having a `"Victory"` Event Item, that +is required to finish the game. This makes it very clear when the player finishes, rather than only seeing their last +relevant Item. Events function just like any other Location, and can still have their own access rules, etc. +By convention, the Event "pair" of Location and Item typically have the same name, though this is not a requirement. +They must not exist in the `name_to_id` lookups, as they have no ID. + +The most common way to create an Event pair is to create and place the Item on the Location as soon as it's created: -Events can be used to either simplify the logic or to get better spoiler logs. -Events will show up in the spoiler playthrough but they do not represent actual -items or locations within the game. +```python +from worlds.AutoWorld import World +from BaseClasses import ItemClassification +from .subclasses import MyGameLocation, MyGameItem -There is one special case for events: Victory. To get the win condition to show -up in the spoiler log, you create an event item and place it at an event -location with the `access_rules` for game completion. Once that's done, the -world's win condition can be as simple as checking for that item. -By convention the victory event is called `"Victory"`. It can be placed at one -or more event locations based on player options. +class MyGameWorld(World): + victory_loc = MyGameLocation(self.player, "Victory", None) + victory_loc.place_locked_item(MyGameItem("Victory", ItemClassification.progression, None, self.player)) +``` ### Regions -Regions are logical groups of locations that share some common access rules. If -location logic is written from scratch, using regions greatly simplifies the -definition and allows to somewhat easily implement things like entrance -randomizer in logic. +Regions are logical containers that typically hold locations that share some common access rules. If location logic is +written from scratch, using regions greatly simplifies the requirements and can help with implementing things +like entrance randomization in logic. -Regions have a list called `exits`, which are `Entrance` objects representing -transitions to other regions. +Regions have a list called `exits`, containing `Entrance` objects representing transitions to other regions. -There has to be one special region "Menu" from which the logic unfolds. AP -assumes that a player will always be able to return to the "Menu" region by -resetting the game ("Save and quit"). +There must be one special region, "Menu", from which the logic unfolds. AP assumes that a player will always be able to +return to the "Menu" region by resetting the game ("Save and quit"). ### Entrances -An `Entrance` connects to a region, is assigned to region's exits and has rules -to define if it and thus the connected region is accessible. -They can be static (regular logic) or be defined/connected during generation -(entrance randomizer). +An `Entrance` has a `parent_region` and `connected_region`, where it is in the `exits` of its parent, and the +`entrances` of its connected region. The `Entrance` then has rules assigned to it to determine if it can be passed +through, making the connected region accessible. They can be static (regular logic) or be defined/connected during +generation (entrance randomization). ### Access Rules -An access rule is a function that returns `True` or `False` for a `Location` or -`Entrance` based on the current `state` (items that can be collected). +An access rule is a function that returns `True` or `False` for a `Location` or `Entrance` based on the current `state` +(items that have been collected). ### Item Rules -An item rule is a function that returns `True` or `False` for a `Location` based -on a single item. It can be used to reject placement of an item there. - +An item rule is a function that returns `True` or `False` for a `Location` based on a single item. It can be used to +reject the placement of an item there. ## Implementation ### Your World -All code for your world implementation should be placed in a python package in -the `/worlds` directory. The starting point for the package is `__init__.py`. -Conventionally, your world class is placed in that file. +All code for your world implementation should be placed in a python package in the `/worlds` directory. The starting +point for the package is `__init__.py`. Conventionally, your `World` class is placed in that file. -World classes must inherit from the `World` class in `/worlds/AutoWorld.py`, -which can be imported as `from worlds.AutoWorld import World` from your package. +World classes must inherit from the `World` class in `/worlds/AutoWorld.py`, which can be imported as +`from worlds.AutoWorld import World` from your package. AP will pick up your world automatically due to the `AutoWorld` implementation. ### Requirements -If your world needs specific python packages, they can be listed in -`worlds//requirements.txt`. ModuleUpdate.py will automatically -pick up and install them. +If your world needs specific python packages, they can be listed in `worlds//requirements.txt`. +ModuleUpdate.py will automatically pick up and install them. See [pip documentation](https://pip.pypa.io/en/stable/cli/pip_install/#requirements-file-format). ### Relative Imports -AP will only import the `__init__.py`. Depending on code size it makes sense to -use multiple files and use relative imports to access them. +AP will only import the `__init__.py`. Depending on code size, it may make sense to use multiple files and use relative +imports to access them. -e.g. `from .options import MyGameOptions` from your `__init__.py` will load -`world/[world_name]/options.py` and make its `MyGameOptions` accessible. +e.g. `from .options import MyGameOptions` from your `__init__.py` will load `world/[world_name]/options.py` and make +its `MyGameOptions` accessible. -When imported names pile up it may be easier to use `from . import options` -and access the variable as `options.MyGameOptions`. +When imported names pile up, it may be easier to use `from . import options` and access the variable as +`options.MyGameOptions`. -Imports from directories outside your world should use absolute imports. -Correct use of relative / absolute imports is required for zipped worlds to -function, see [apworld specification.md](apworld%20specification.md). +Imports from directories outside your world should use absolute imports. Correct use of relative / absolute imports is +required for zipped worlds to function, see [apworld specification.md](apworld%20specification.md). ### Your Item Type -Each world uses its own subclass of `BaseClasses.Item`. The constructor can be -overridden to attach additional data to it, e.g. "price in shop". -Since the constructor is only ever called from your code, you can add whatever -arguments you like to the constructor. +Each world uses its own subclass of `BaseClasses.Item`. The constructor can be overridden to attach additional data to +it, e.g. "price in shop". Since the constructor is only ever called from your code, you can add whatever arguments you +like to the constructor. + +In its simplest form, we only set the game name and use the default constructor: -In its simplest form we only set the game name and use the default constructor ```python from BaseClasses import Item + class MyGameItem(Item): game: str = "My Game" ``` -By convention this class definition will either be placed in your `__init__.py` -or your `items.py`. For a more elaborate example see `worlds/oot/Items.py`. -### Your location type +By convention, this class definition will either be placed in your `__init__.py` or your `items.py`. For a more +elaborate example see +[`worlds/oot/Items.py`](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/oot/Items.py). + +### Your Location Type + +The same thing we did for items above, we will now do for locations: -The same we have done for items above, we will do for locations ```python from BaseClasses import Location + class MyGameLocation(Location): game: str = "My Game" # override constructor to automatically mark event locations as such - def __init__(self, player: int, name = "", code = None, parent = None) -> None: + def __init__(self, player: int, name="", code=None, parent=None) -> None: super(MyGameLocation, self).__init__(player, name, code, parent) self.event = code is None ``` -in your `__init__.py` or your `locations.py`. - -### Options - -By convention options are defined in `options.py` and will be used when parsing -the players' yaml files. - -Each option has its own class, inherits from a base option type, has a docstring -to describe it and a `display_name` property for display on the website and in -spoiler logs. - -The actual name as used in the yaml is defined via the field names of a `dataclass` that is -assigned to the world under `self.options_dataclass`. By convention, the strings -that define your option names should be in `snake_case`. - -Common option types are `Toggle`, `DefaultOnToggle`, `Choice`, `Range`. -For more see `Options.py` in AP's base directory. - -#### Toggle, DefaultOnToggle - -These don't need any additional properties defined. After parsing the option, -its `value` will either be True or False. - -#### Range - -Define properties `range_start`, `range_end` and `default`. Ranges will be -displayed as sliders on the website and can be set to random in the yaml. - -#### Choice - -Choices are like toggles, but have more options than just True and False. -Define a property `option_ = ` per selectable value and -`default = ` to set the default selection. Aliases can be set by -defining a property `alias_ = `. - -```python -option_off = 0 -option_on = 1 -option_some = 2 -alias_disabled = 0 -alias_enabled = 1 -default = 0 -``` - -#### Sample -```python -# options.py - -from dataclasses import dataclass -from Options import Toggle, Range, Choice, PerGameCommonOptions - -class Difficulty(Choice): - """Sets overall game difficulty.""" - display_name = "Difficulty" - option_easy = 0 - option_normal = 1 - option_hard = 2 - alias_beginner = 0 # same as easy - alias_expert = 2 # same as hard - default = 1 # default to normal - -class FinalBossHP(Range): - """Sets the HP of the final boss""" - display_name = "Final Boss HP" - range_start = 100 - range_end = 10000 - default = 2000 - -class FixXYZGlitch(Toggle): - """Fixes ABC when you do XYZ""" - display_name = "Fix XYZ Glitch" - -# By convention, we call the options dataclass `Options`. -# It has to be derived from 'PerGameCommonOptions'. -@dataclass -class MyGameOptions(PerGameCommonOptions): - difficulty: Difficulty - final_boss_hp: FinalBossHP - fix_xyz_glitch: FixXYZGlitch -``` - -```python -# __init__.py - -from worlds.AutoWorld import World -from .options import MyGameOptions # import the options dataclass - -class MyGameWorld(World): - # ... - options_dataclass = MyGameOptions # assign the options dataclass to the world - options: MyGameOptions # typing for option results - # ... -``` +in your `__init__.py` or your `locations.py`. ### A World Class Skeleton @@ -483,7 +402,6 @@ from worlds.AutoWorld import World from BaseClasses import Region, Location, Entrance, Item, RegionType, ItemClassification - class MyGameItem(Item): # or from Items import MyGameItem game = "My Game" # name of the game/world this item is from @@ -492,7 +410,6 @@ class MyGameLocation(Location): # or from Locations import MyGameLocation game = "My Game" # name of the game/world this location is in - class MyGameSettings(settings.Group): class RomFile(settings.SNESRomPath): """Insert help text for host.yaml here.""" @@ -511,7 +428,7 @@ class MyGameWorld(World): # ID of first item and location, could be hard-coded but code may be easier # to read with this as a property. base_id = 1234 - # Instead of dynamic numbering, IDs could be part of data. + # instead of dynamic numbering, IDs could be part of data # The following two dicts are required for the generation to know which # items exist. They could be generated from json or something else. They can @@ -530,74 +447,106 @@ class MyGameWorld(World): ### Generation -The world has to provide the following things for generation +The world has to provide the following things for generation: -* the properties mentioned above +* the properties mentioned above * additions to the item pool * additions to the regions list: at least one called "Menu" * locations placed inside those regions * a `def create_item(self, item: str) -> MyGameItem` to create any item on demand -* applying `self.multiworld.push_precollected` for world defined start inventory -* `required_client_version: Tuple[int, int, int]` - Optional client version as tuple of 3 ints to make sure the client is compatible to - this world (e.g. implements all required features) when connecting. +* applying `self.multiworld.push_precollected` for world-defined start inventory -In addition, the following methods can be implemented and are called in this order during generation +In addition, the following methods can be implemented and are called in this order during generation: -* `stage_assert_generate(cls, multiworld)` is a class method called at the start of - generation to check the existence of prerequisite files, usually a ROM for +* `stage_assert_generate(cls, multiworld: MultiWorld)` + a class method called at the start of generation to check for the existence of prerequisite files, usually a ROM for games which require one. * `generate_early(self)` - called per player before any items or locations are created. You can set properties on your world here. Already has - access to player options and RNG. This is the earliest step where the world should start setting up for the current - multiworld as any steps before this, the multiworld itself is still getting set up + called per player before any items or locations are created. You can set properties on your + world here. Already has access to player options and RNG. This is the earliest step where the world should start + setting up for the current multiworld, as the multiworld itself is still setting up before this point. * `create_regions(self)` - called to place player's regions and their locations into the MultiWorld's regions list. If it's - hard to separate, this can be done during `generate_early` or `create_items` as well. + called to place player's regions and their locations into the MultiWorld's regions list. + If it's hard to separate, this can be done during `generate_early` or `create_items` as well. * `create_items(self)` - called to place player's items into the MultiWorld's itempool. After this step all regions and items have to be in - the MultiWorld's regions and itempool, and these lists should not be modified afterwards. + called to place player's items into the MultiWorld's itempool. After this step all regions + and items have to be in the MultiWorld's regions and itempool, and these lists should not be modified afterward. * `set_rules(self)` - called to set access and item rules on locations and entrances. - Locations have to be defined before this, or rule application can miss them. + called to set access and item rules on locations and entrances. * `generate_basic(self)` - called after the previous steps. Some placement and player specific - randomizations can be done here. -* `pre_fill(self)`, `fill_hook(self)` and `post_fill(self)` are called to modify item placement - before, during and after the regular fill process, before `generate_output`. - If items need to be placed during pre_fill, these items can be determined - and created using `get_prefill_items` -* `generate_output(self, output_directory: str)` that creates the output - files if there is output to be generated. When this is - called, `self.multiworld.get_locations(self.player)` has all locations for the player, with - attribute `item` pointing to the item. - `location.item.player` can be used to see if it's a local item. + player-specific randomization that does not affect logic can be done here. +* `pre_fill(self)`, `fill_hook(self)` and `post_fill(self)` + called to modify item placement before, during, and after the regular fill process; all finishing before + `generate_output`. Any items that need to be placed during `pre_fill` should not exist in the itempool, and if there + are any items that need to be filled this way, but need to be in state while you fill other items, they can be + returned from `get_prefill_items`. +* `generate_output(self, output_directory: str)` + creates the output files if there is output to be generated. When this is called, + `self.multiworld.get_locations(self.player)` has all locations for the player, with attribute `item` pointing to the + item. `location.item.player` can be used to see if it's a local item. * `fill_slot_data(self)` and `modify_multidata(self, multidata: Dict[str, Any])` can be used to modify the data that will be used by the server to host the MultiWorld. +All instance methods can, optionally, have a class method defined which will be called after all instance methods are +finished running, by defining a method with `stage_` in front of the method name. These class methods will have the +args `(cls, multiworld: MultiWorld)`, followed by any other args that the relevant instance method has. #### generate_early ```python def generate_early(self) -> None: - # read player settings to world instance + # read player options to world instance self.final_boss_hp = self.options.final_boss_hp.value ``` +#### create_regions + +```python +def create_regions(self) -> None: + # Add regions to the multiworld. "Menu" is the required starting point. + # Arguments to Region() are name, player, multiworld, and optionally hint_text + menu_region = Region("Menu", self.player, self.multiworld) + self.multiworld.regions.append(menu_region) # or use += [menu_region...] + + main_region = Region("Main Area", self.player, self.multiworld) + # add main area's locations to main area (all but final boss) + main_region.add_locations(main_region_locations, MyGameLocation) + # or + # main_region.locations = \ + # [MyGameLocation(self.player, location_name, self.location_name_to_id[location_name], main_region] + self.multiworld.regions.append(main_region) + + boss_region = Region("Boss Room", self.player, self.multiworld) + # add event to Boss Room + boss_region.locations.append(MyGameLocation(self.player, "Final Boss", None, boss_region)) + + # if entrances are not randomized, they should be connected here, otherwise they can also be connected at a later stage + # create Entrances and connect the Regions + menu_region.connect(main_region) # connects the "Menu" and "Main Area", can also pass a rule + # or + main_region.add_exits({"Boss Room": "Boss Door"}, {"Boss Room": lambda state: state.has("Sword", self.player)}) + # connects the "Main Area" and "Boss Room" regions, and places a rule requiring the "Sword" item to traverse + + # if setting location access rules from data is easier here, set_rules can possibly be omitted +``` + #### create_item ```python -# we need a way to know if an item provides progress in the game ("key item") -# this can be part of the items definition, or depend on recipe randomization +# we need a way to know if an item provides progress in the game ("key item") this can be part of the items definition, +# or depend on recipe randomization from .items import is_progression # this is just a dummy + def create_item(self, item: str) -> MyGameItem: - # This is called when AP wants to create an item by name (for plando) or - # when you call it from your own code. - classification = ItemClassification.progression if is_progression(item) else \ - ItemClassification.filler - return MyGameItem(item, classification, self.item_name_to_id[item], - self.player) + # this is called when AP wants to create an item by name (for plando) or when you call it from your own code + classification = ItemClassification.progression if is_progression(item) else + ItemClassification.filler + + +return MyGameItem(item, classification, self.item_name_to_id[item], + self.player) + def create_event(self, event: str) -> MyGameItem: # while we are at it, we can also add a helper to create events @@ -610,8 +559,7 @@ def create_event(self, event: str) -> MyGameItem: def create_items(self) -> None: # Add items to the Multiworld. # If there are two of the same item, the item has to be twice in the pool. - # Which items are added to the pool may depend on player settings, - # e.g. custom win condition like triforce hunt. + # Which items are added to the pool may depend on player options, e.g. custom win condition like triforce hunt. # Having an item in the start inventory won't remove it from the pool. # If an item can't have duplicates it has to be excluded manually. @@ -627,67 +575,10 @@ def create_items(self) -> None: # itempool and number of locations should match up. # If this is not the case we want to fill the itempool with junk. - junk = 0 # calculate this based on player settings + junk = 0 # calculate this based on player options self.multiworld.itempool += [self.create_item("nothing") for _ in range(junk)] ``` -#### create_regions - -```python -def create_regions(self) -> None: - # Add regions to the multiworld. "Menu" is the required starting point. - # Arguments to Region() are name, player, world, and optionally hint_text - menu_region = Region("Menu", self.player, self.multiworld) - self.multiworld.regions.append(menu_region) # or use += [menu_region...] - - main_region = Region("Main Area", self.player, self.multiworld) - # Add main area's locations to main area (all but final boss) - main_region.add_locations(main_region_locations, MyGameLocation) - # or - # main_region.locations = \ - # [MyGameLocation(self.player, location_name, self.location_name_to_id[location_name], main_region] - self.multiworld.regions.append(main_region) - - boss_region = Region("Boss Room", self.player, self.multiworld) - # Add event to Boss Room - boss_region.locations.append(MyGameLocation(self.player, "Final Boss", None, boss_region)) - - # If entrances are not randomized, they should be connected here, - # otherwise they can also be connected at a later stage. - # Create Entrances and connect the Regions - menu_region.connect(main_region) # connects the "Menu" and "Main Area", can also pass a rule - # or - main_region.add_exits({"Boss Room": "Boss Door"}, {"Boss Room": lambda state: state.has("Sword", self.player)}) - # Connects the "Main Area" and "Boss Room" regions, and places a rule requiring the "Sword" item to traverse - - # If setting location access rules from data is easier here, set_rules can - # possibly omitted. -``` - -#### generate_basic - -```python -def generate_basic(self) -> None: - # place "Victory" at "Final Boss" and set collection as win condition - self.multiworld.get_location("Final Boss", self.player) - .place_locked_item(self.create_event("Victory")) - self.multiworld.completion_condition[self.player] = - lambda state: state.has("Victory", self.player) - - # place item Herb into location Chest1 for some reason - item = self.create_item("Herb") - self.multiworld.get_location("Chest1", self.player).place_locked_item(item) - # in most cases it's better to do this at the same time the itempool is - # filled to avoid accidental duplicates: - # manually placed and still in the itempool - - # for debugging purposes, you may want to visualize the layout of your world. Uncomment the following code to - # write a PlantUML diagram to the file "my_world.puml" that can help you see whether your regions and locations - # are connected and placed as desired - # from Utils import visualize_regions - # visualize_regions(self.multiworld.get_region("Menu", self.player), "my_world.puml") -``` - ### Setting Rules ```python @@ -703,6 +594,7 @@ def set_rules(self) -> None: # set a simple rule for an region set_rule(self.multiworld.get_entrance("Boss Door", self.player), lambda state: state.has("Boss Key", self.player)) + # location.access_rule = ... is likely to be a bit faster # combine rules to require two items add_rule(self.multiworld.get_location("Chest2", self.player), lambda state: state.has("Sword", self.player)) @@ -730,59 +622,80 @@ def set_rules(self) -> None: # get_item_type needs to take player/world into account # if MyGameItem has a type property, a more direct implementation would be add_item_rule(self.multiworld.get_location("Chest5", self.player), - lambda item: item.player != self.player or\ + lambda item: item.player != self.player or item.my_type == "weapon") # location.item_rule = ... is likely to be a bit faster -``` -### Logic Mixin + # place "Victory" at "Final Boss" and set collection as win condition + self.multiworld.get_location("Final Boss", self.player).place_locked_item(self.create_event("Victory")) -While lambdas and events could do pretty much anything, by convention we -implement more complex logic in logic mixins, even if there is no need to add -properties to the `BaseClasses.CollectionState` state object. - -When importing a file that defines a class that inherits from -`worlds.AutoWorld.LogicMixin` the state object's class is automatically extended by -the mixin's members. These members should be prefixed with underscore following -the name of the implementing world. This is due to sharing a namespace with all -other logic mixins. - -Typical uses are defining methods that are used instead of `state.has` -in lambdas, e.g.`state.mygame_has(custom, player)` or recurring checks -like `state.mygame_can_do_something(player)` to simplify lambdas. -Private members, only accessible from mixins, should start with `_mygame_`, -public members with `mygame_`. - -More advanced uses could be to add additional variables to the state object, -override `World.collect(self, state, item)` and `remove(self, state, item)` -to update the state object, and check those added variables in added methods. -Please do this with caution and only when necessary. + self.multiworld.completion_condition[self.player] = lambda state: state.has("Victory", self.player) + +# for debugging purposes, you may want to visualize the layout of your world. Uncomment the following code to +# write a PlantUML diagram to the file "my_world.puml" that can help you see whether your regions and locations +# are connected and placed as desired +# from Utils import visualize_regions +# visualize_regions(self.multiworld.get_region("Menu", self.player), "my_world.puml") +``` -#### Sample +### Custom Logic Rules + +Custom methods can be defined for your logic rules. The access rule that ultimately gets assigned to the Location or +Entrance should be +a [`CollectionRule`](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/generic/Rules.py#L9). +Typically, this is done by defining a lambda expression on demand at the relevant bit, typically calling other +functions, but this can also be achieved by defining a method with the appropriate format and assigning it directly. +For an example, see [The Messenger](/worlds/messenger/rules.py). ```python # logic.py -from worlds.AutoWorld import LogicMixin +from BaseClasses import CollectionState + -class MyGameLogic(LogicMixin): - def mygame_has_key(self, player: int) -> bool: - # Arguments above are free to choose - # MultiWorld can be accessed through self.multiworld, explicitly passing in - # MyGameWorld instance for easy options access is also a valid approach - return self.has("key", player) # or whatever +def mygame_has_key(self, state: CollectionState, player: int) -> bool: + # More arguments above are free to choose, since you can expect this is only called in your world + # MultiWorld can be accessed through state.multiworld. + # Explicitly passing in MyGameWorld instance for easy options access is also a valid approach, but it's generally + # better to check options before rule assignment since the individual functions can be called thousands of times + return state.has("key", player) # or whatever ``` + ```python # __init__.py from worlds.generic.Rules import set_rule -import .logic # apply the mixin by importing its file +from . import logic + class MyGameWorld(World): # ... def set_rules(self) -> None: set_rule(self.multiworld.get_location("A Door", self.player), - lambda state: state.mygame_has_key(self.player)) + lambda state: logic.mygame_has_key(state, self.player)) +``` + +### Logic Mixin + +While lambdas and events can do pretty much anything, more complex logic can be handled in logic mixins. + +When importing a file that defines a class that inherits from `worlds.AutoWorld.LogicMixin`, the `CollectionState` class +is automatically extended by the mixin's members. These members should be prefixed with the name of the implementing +world since the namespace is shared with all other logic mixins. + +Some uses could be to add additional variables to the state object, or to have a custom state machine that gets modified +with the state. +Please do this with caution and only when necessary. + +#### pre_fill + +```python +def pre_fill(self) -> None: + # place item Herb into location Chest1 for some reason + item = self.create_item("Herb") + self.multiworld.get_location("Chest1", self.player).place_locked_item(item) + # in most cases it's better to do this at the same time the itempool is + # filled to avoid accidental duplicates, such as manually placed and still in the itempool ``` ### Generate Output @@ -792,9 +705,9 @@ from .mod import generate_mod def generate_output(self, output_directory: str) -> None: - # How to generate the mod or ROM highly depends on the game - # if the mod is written in Lua, Jinja can be used to fill a template - # if the mod reads a json file, `json.dump()` can be used to generate that + # How to generate the mod or ROM highly depends on the game. + # If the mod is written in Lua, Jinja can be used to fill a template. + # If the mod reads a json file, `json.dump()` can be used to generate that. # code below is a dummy data = { "seed": self.multiworld.seed_name, # to verify the server's multiworld @@ -804,8 +717,7 @@ def generate_output(self, output_directory: str) -> None: for location in self.multiworld.get_filled_locations(self.player)}, # store start_inventory from player's .yaml # make sure to mark as not remote_start_inventory when connecting if stored in rom/mod - "starter_items": [item.name for item - in self.multiworld.precollected_items[self.player]], + "starter_items": [item.name for item in self.multiworld.precollected_items[self.player]], } # add needed option results to the dictionary @@ -824,20 +736,20 @@ def generate_output(self, output_directory: str) -> None: ### Slot Data If the game client needs to know information about the generated seed, a preferred method of transferring the data -is through the slot data. This can be filled from the `fill_slot_data` method of your world by returning a `Dict[str, Any]`, -but should be limited to data that is absolutely necessary to not waste resources. Slot data is sent to your client once -it has successfully [connected](network%20protocol.md#connected). -If you need to know information about locations in your world, instead -of propagating the slot data, it is preferable to use [LocationScouts](network%20protocol.md#locationscouts) since that -data already exists on the server. The most common usage of slot data is to send option results that the client needs -to be aware of. +is through the slot data. This is filled with the `fill_slot_data` method of your world by returning +a `Dict[str, Any]`, but, to not waste resources, should be limited to data that is absolutely necessary. Slot data is +sent to your client once it has successfully [connected](network%20protocol.md#connected). +If you need to know information about locations in your world, instead of propagating the slot data, it is preferable +to use [LocationScouts](network%20protocol.md#locationscouts), since that data already exists on the server. The most +common usage of slot data is sending option results that the client needs to be aware of. ```python def fill_slot_data(self) -> Dict[str, Any]: - # in order for our game client to handle the generated seed correctly we need to know what the user selected - # for their difficulty and final boss HP - # a dictionary returned from this method gets set as the slot_data and will be sent to the client after connecting - # the options dataclass has a method to return a `Dict[str, Any]` of each option name provided and the option's value + # In order for our game client to handle the generated seed correctly we need to know what the user selected + # for their difficulty and final boss HP. + # A dictionary returned from this method gets set as the slot_data and will be sent to the client after connecting. + # The options dataclass has a method to return a `Dict[str, Any]` of each option name provided and the relevant + # option's value. return self.options.as_dict("difficulty", "final_boss_hp") ``` @@ -847,15 +759,17 @@ Each world implementation should have a tutorial and a game info page. These are the `.md` files in your world's `/docs` directory. #### Game Info + The game info page is for a short breakdown of what your game is and how it works in Archipelago. Any additional information that may be useful to the player when learning your randomizer should also go here. The file name format is `_.md`. While you can write these docs for multiple languages, currently only the english version is displayed on the website. #### Tutorials + Your game can have as many tutorials in as many languages as you like, with each one having a relevant `Tutorial` -defined in the `WebWorld`. The file name you use aren't particularly important, but it should be descriptive of what -the tutorial is covering, and the name of the file must match the relative URL provided in the `Tutorial`. Currently, +defined in the `WebWorld`. The file name you use isn't particularly important, but it should be descriptive of what +the tutorial covers, and the name of the file must match the relative URL provided in the `Tutorial`. Currently, the JS that determines this ignores the provided file name and will search for `game/document_lang.md`, where `game/document/lang` is the provided URL. @@ -874,12 +788,13 @@ from test.bases import WorldTestBase class MyGameTestBase(WorldTestBase): - game = "My Game" + game = "My Game" ``` -Next using the rules defined in the above `set_rules` we can test that the chests have the correct access rules. +Next, using the rules defined in the above `set_rules` we can test that the chests have the correct access rules. Example `test_chest_access.py` + ```python from . import MyGameTestBase @@ -889,15 +804,15 @@ class TestChestAccess(MyGameTestBase): """Test locations that require a sword""" locations = ["Chest1", "Chest2"] items = [["Sword"]] - # this will test that each location can't be accessed without the "Sword", but can be accessed once obtained. + # this will test that each location can't be accessed without the "Sword", but can be accessed once obtained self.assertAccessDependency(locations, items) def test_any_weapon_chests(self) -> None: """Test locations that require any weapon""" locations = [f"Chest{i}" for i in range(3, 6)] items = [["Sword"], ["Axe"], ["Spear"]] - # this will test that chests 3-5 can't be accessed without any weapon, but can be with just one of them. + # this will test that chests 3-5 can't be accessed without any weapon, but can be with just one of them self.assertAccessDependency(locations, items) ``` -For more information on tests check the [tests doc](tests.md). +For more information on tests, check the [tests doc](tests.md). From 3a51c035ac0fa6c562f46bc3e4276be9455c022a Mon Sep 17 00:00:00 2001 From: Star Rauchenberger Date: Wed, 31 Jan 2024 01:56:35 -0500 Subject: [PATCH 17/38] Lingo: Enable start_inventory_from_pool (#2781) --- worlds/lingo/__init__.py | 7 +++++-- worlds/lingo/options.py | 3 ++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/worlds/lingo/__init__.py b/worlds/lingo/__init__.py index 08896744500..2f935419325 100644 --- a/worlds/lingo/__init__.py +++ b/worlds/lingo/__init__.py @@ -82,9 +82,8 @@ def create_items(self): skips = int(non_traps * skip_percentage / 100.0) non_skips = non_traps - skips - filler_list = [":)", "The Feeling of Being Lost", "Wanderlust", "Empty White Hallways"] for i in range(0, non_skips): - pool.append(self.create_item(filler_list[i % len(filler_list)])) + pool.append(self.create_item(self.get_filler_item_name())) for i in range(0, skips): pool.append(self.create_item("Puzzle Skip")) @@ -130,3 +129,7 @@ def fill_slot_data(self): slot_data["painting_entrance_to_exit"] = self.player_logic.painting_mapping return slot_data + + def get_filler_item_name(self) -> str: + filler_list = [":)", "The Feeling of Being Lost", "Wanderlust", "Empty White Hallways"] + return self.random.choice(filler_list) diff --git a/worlds/lingo/options.py b/worlds/lingo/options.py index ec6158fab5a..ed1426450eb 100644 --- a/worlds/lingo/options.py +++ b/worlds/lingo/options.py @@ -1,6 +1,6 @@ from dataclasses import dataclass -from Options import Toggle, Choice, DefaultOnToggle, Range, PerGameCommonOptions +from Options import Toggle, Choice, DefaultOnToggle, Range, PerGameCommonOptions, StartInventoryPool class ShuffleDoors(Choice): @@ -136,3 +136,4 @@ class LingoOptions(PerGameCommonOptions): trap_percentage: TrapPercentage puzzle_skip_percentage: PuzzleSkipPercentage death_link: DeathLink + start_inventory_from_pool: StartInventoryPool From 140f8025647ab38ecf9d8acefe5f48f36ab69ad1 Mon Sep 17 00:00:00 2001 From: t3hf1gm3nt <59876300+t3hf1gm3nt@users.noreply.github.com> Date: Wed, 31 Jan 2024 02:01:55 -0500 Subject: [PATCH 18/38] LTTP: Update playerSettings.yaml to require AP version 0.4.4 (#2737) Updating yaml to make sure people have the proper required version if they ever use this template to play with KDS --- playerSettings.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/playerSettings.yaml b/playerSettings.yaml index f9585da246b..b6b474a9fff 100644 --- a/playerSettings.yaml +++ b/playerSettings.yaml @@ -26,7 +26,7 @@ name: YourName{number} # Your name in-game. Spaces will be replaced with undersc game: # Pick a game to play A Link to the Past: 1 requires: - version: 0.4.3 # Version of Archipelago required for this yaml to work as expected. + version: 0.4.4 # Version of Archipelago required for this yaml to work as expected. A Link to the Past: progression_balancing: # A system that can move progression earlier, to try and prevent the player from getting stuck and bored early. From 57cb971177c22f723dd173f3fe29446514b61541 Mon Sep 17 00:00:00 2001 From: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com> Date: Wed, 31 Jan 2024 21:07:07 +0100 Subject: [PATCH 19/38] The Witness: Junk hints for Shivers, Mystic Quest and Heretic (#2592) --- worlds/witness/hints.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/worlds/witness/hints.py b/worlds/witness/hints.py index c00827feee2..e2d1069bd1d 100644 --- a/worlds/witness/hints.py +++ b/worlds/witness/hints.py @@ -75,6 +75,9 @@ "Have you tried Bumper Stickers?\nMaybe after spending so much time on this island, you are longing for a simpler puzzle game.", "Have you tried Pokemon Emerald?\nI'm going to say it: 10/10, just the right amount of water.", "Have you tried Terraria?\nA prime example of a survival sandbox game that beats the \"Wide as an ocean, deep as a puddle\" allegations.", + "Have you tried Final Fantasy Mystic Quest?\nApparently, it was made in an attempt to simplify Final Fantasy for the western market.\nThey were right, I suck at RPGs.", + "Have you tried Shivers?\nWitness 2 should totally feature a haunted Museum.", + "Have you tried Heretic?\nWait, there is a Doom Engine game where you can look UP AND DOWN???", "One day I was fascinated by the subject of generation of waves by wind.", "I don't like sandwiches. Why would you think I like sandwiches? Have you ever seen me with a sandwich?", @@ -148,7 +151,7 @@ "You don't have Boat? Invisible boat time!\nYou do have boat? Boat clipping time!", "Cet indice est en français. Nous nous excusons de tout inconvénients engendrés par cela.", "How many of you have personally witnessed a total solar eclipse?", - "In the Treehouse area, you will find \n[Error: Data not found] progression items.", + "In the Treehouse area, you will find 69 progression items.\nNice.\n(Source: Just trust me)", "Lingo\nLingoing\nLingone", "The name of the captain was Albert Einstein.", "Panel impossible Sigma plz fix", From 33237bd5c0530830e3cdd5097596ff86a42042ed Mon Sep 17 00:00:00 2001 From: Alchav <59858495+Alchav@users.noreply.github.com> Date: Sat, 3 Feb 2024 00:45:37 -0500 Subject: [PATCH 20/38] LTTP: Create Hyrule Castle Big Key Rule On Universal Small Keys Option (#2787) --- worlds/alttp/Rules.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/worlds/alttp/Rules.py b/worlds/alttp/Rules.py index 8a04f87afa0..98ab805b5c0 100644 --- a/worlds/alttp/Rules.py +++ b/worlds/alttp/Rules.py @@ -968,6 +968,9 @@ def standard_rules(world, player): set_rule(world.get_location('Sewers - Key Rat Key Drop', player), lambda state: state._lttp_has_key('Small Key (Hyrule Castle)', player, 3)) + else: + set_rule(world.get_location('Hyrule Castle - Zelda\'s Chest', player), + lambda state: state.has('Big Key (Hyrule Castle)', player)) def toss_junk_item(world, player): items = ['Rupees (20)', 'Bombs (3)', 'Arrows (10)', 'Rupees (5)', 'Rupee (1)', 'Bombs (10)', From 6c19bc42bb714ca0f5ef7e435e79b10543a1f318 Mon Sep 17 00:00:00 2001 From: Fabian Dill Date: Sun, 4 Feb 2024 09:09:07 +0100 Subject: [PATCH 21/38] Tests: add world load benchmark (#2768) --- test/benchmark/__init__.py | 132 ++-------------------------------- test/benchmark/load_worlds.py | 27 +++++++ test/benchmark/locations.py | 101 ++++++++++++++++++++++++++ test/benchmark/path_change.py | 16 +++++ test/benchmark/time_it.py | 23 ++++++ worlds/__init__.py | 10 ++- 6 files changed, 181 insertions(+), 128 deletions(-) create mode 100644 test/benchmark/load_worlds.py create mode 100644 test/benchmark/locations.py create mode 100644 test/benchmark/path_change.py create mode 100644 test/benchmark/time_it.py diff --git a/test/benchmark/__init__.py b/test/benchmark/__init__.py index 5f890e85300..6c80c60b89d 100644 --- a/test/benchmark/__init__.py +++ b/test/benchmark/__init__.py @@ -1,127 +1,7 @@ -import time - - -class TimeIt: - def __init__(self, name: str, time_logger=None): - self.name = name - self.logger = time_logger - self.timer = None - self.end_timer = None - - def __enter__(self): - self.timer = time.perf_counter() - return self - - @property - def dif(self): - return self.end_timer - self.timer - - def __exit__(self, exc_type, exc_val, exc_tb): - if not self.end_timer: - self.end_timer = time.perf_counter() - if self.logger: - self.logger.info(f"{self.dif:.4f} seconds in {self.name}.") - - if __name__ == "__main__": - import argparse - import logging - import gc - import collections - import typing - - # makes this module runnable from its folder. - import sys - import os - sys.path.remove(os.path.dirname(__file__)) - new_home = os.path.normpath(os.path.join(os.path.dirname(__file__), os.pardir, os.pardir)) - os.chdir(new_home) - sys.path.append(new_home) - - from Utils import init_logging, local_path - local_path.cached_path = new_home - from BaseClasses import MultiWorld, CollectionState, Location - from worlds import AutoWorld - from worlds.AutoWorld import call_all - - init_logging("Benchmark Runner") - logger = logging.getLogger("Benchmark") - - - class BenchmarkRunner: - gen_steps: typing.Tuple[str, ...] = ( - "generate_early", "create_regions", "create_items", "set_rules", "generate_basic", "pre_fill") - rule_iterations: int = 100_000 - - if sys.version_info >= (3, 9): - @staticmethod - def format_times_from_counter(counter: collections.Counter[str], top: int = 5) -> str: - return "\n".join(f" {time:.4f} in {name}" for name, time in counter.most_common(top)) - else: - @staticmethod - def format_times_from_counter(counter: collections.Counter, top: int = 5) -> str: - return "\n".join(f" {time:.4f} in {name}" for name, time in counter.most_common(top)) - - def location_test(self, test_location: Location, state: CollectionState, state_name: str) -> float: - with TimeIt(f"{test_location.game} {self.rule_iterations} " - f"runs of {test_location}.access_rule({state_name})", logger) as t: - for _ in range(self.rule_iterations): - test_location.access_rule(state) - # if time is taken to disentangle complex ref chains, - # this time should be attributed to the rule. - gc.collect() - return t.dif - - def main(self): - for game in sorted(AutoWorld.AutoWorldRegister.world_types): - summary_data: typing.Dict[str, collections.Counter[str]] = { - "empty_state": collections.Counter(), - "all_state": collections.Counter(), - } - try: - multiworld = MultiWorld(1) - multiworld.game[1] = game - multiworld.player_name = {1: "Tester"} - multiworld.set_seed(0) - multiworld.state = CollectionState(multiworld) - args = argparse.Namespace() - for name, option in AutoWorld.AutoWorldRegister.world_types[game].options_dataclass.type_hints.items(): - setattr(args, name, { - 1: option.from_any(getattr(option, "default")) - }) - multiworld.set_options(args) - - gc.collect() - for step in self.gen_steps: - with TimeIt(f"{game} step {step}", logger): - call_all(multiworld, step) - gc.collect() - - locations = sorted(multiworld.get_unfilled_locations()) - if not locations: - continue - - all_state = multiworld.get_all_state(False) - for location in locations: - time_taken = self.location_test(location, multiworld.state, "empty_state") - summary_data["empty_state"][location.name] = time_taken - - time_taken = self.location_test(location, all_state, "all_state") - summary_data["all_state"][location.name] = time_taken - - total_empty_state = sum(summary_data["empty_state"].values()) - total_all_state = sum(summary_data["all_state"].values()) - - logger.info(f"{game} took {total_empty_state/len(locations):.4f} " - f"seconds per location in empty_state and {total_all_state/len(locations):.4f} " - f"in all_state. (all times summed for {self.rule_iterations} runs.)") - logger.info(f"Top times in empty_state:\n" - f"{self.format_times_from_counter(summary_data['empty_state'])}") - logger.info(f"Top times in all_state:\n" - f"{self.format_times_from_counter(summary_data['all_state'])}") - - except Exception as e: - logger.exception(e) - - runner = BenchmarkRunner() - runner.main() + import path_change + path_change.change_home() + import load_worlds + load_worlds.run_load_worlds_benchmark() + import locations + locations.run_locations_benchmark() diff --git a/test/benchmark/load_worlds.py b/test/benchmark/load_worlds.py new file mode 100644 index 00000000000..3b001699f4c --- /dev/null +++ b/test/benchmark/load_worlds.py @@ -0,0 +1,27 @@ +def run_load_worlds_benchmark(): + """List worlds and their load time. + Note that any first-time imports will be attributed to that world, as it is cached afterwards. + Likely best used with isolated worlds to measure their time alone.""" + import logging + + from Utils import init_logging + + # get some general imports cached, to prevent it from being attributed to one world. + import orjson + orjson.loads("{}") # orjson runs initialization on first use + + import BaseClasses, Launcher, Fill + + from worlds import world_sources + + init_logging("Benchmark Runner") + logger = logging.getLogger("Benchmark") + + for module in world_sources: + logger.info(f"{module} took {module.time_taken:.4f} seconds.") + + +if __name__ == "__main__": + from path_change import change_home + change_home() + run_load_worlds_benchmark() diff --git a/test/benchmark/locations.py b/test/benchmark/locations.py new file mode 100644 index 00000000000..f2209eb689e --- /dev/null +++ b/test/benchmark/locations.py @@ -0,0 +1,101 @@ +def run_locations_benchmark(): + import argparse + import logging + import gc + import collections + import typing + import sys + + from time_it import TimeIt + + from Utils import init_logging + from BaseClasses import MultiWorld, CollectionState, Location + from worlds import AutoWorld + from worlds.AutoWorld import call_all + + init_logging("Benchmark Runner") + logger = logging.getLogger("Benchmark") + + class BenchmarkRunner: + gen_steps: typing.Tuple[str, ...] = ( + "generate_early", "create_regions", "create_items", "set_rules", "generate_basic", "pre_fill") + rule_iterations: int = 100_000 + + if sys.version_info >= (3, 9): + @staticmethod + def format_times_from_counter(counter: collections.Counter[str], top: int = 5) -> str: + return "\n".join(f" {time:.4f} in {name}" for name, time in counter.most_common(top)) + else: + @staticmethod + def format_times_from_counter(counter: collections.Counter, top: int = 5) -> str: + return "\n".join(f" {time:.4f} in {name}" for name, time in counter.most_common(top)) + + def location_test(self, test_location: Location, state: CollectionState, state_name: str) -> float: + with TimeIt(f"{test_location.game} {self.rule_iterations} " + f"runs of {test_location}.access_rule({state_name})", logger) as t: + for _ in range(self.rule_iterations): + test_location.access_rule(state) + # if time is taken to disentangle complex ref chains, + # this time should be attributed to the rule. + gc.collect() + return t.dif + + def main(self): + for game in sorted(AutoWorld.AutoWorldRegister.world_types): + summary_data: typing.Dict[str, collections.Counter[str]] = { + "empty_state": collections.Counter(), + "all_state": collections.Counter(), + } + try: + multiworld = MultiWorld(1) + multiworld.game[1] = game + multiworld.player_name = {1: "Tester"} + multiworld.set_seed(0) + multiworld.state = CollectionState(multiworld) + args = argparse.Namespace() + for name, option in AutoWorld.AutoWorldRegister.world_types[game].options_dataclass.type_hints.items(): + setattr(args, name, { + 1: option.from_any(getattr(option, "default")) + }) + multiworld.set_options(args) + + gc.collect() + for step in self.gen_steps: + with TimeIt(f"{game} step {step}", logger): + call_all(multiworld, step) + gc.collect() + + locations = sorted(multiworld.get_unfilled_locations()) + if not locations: + continue + + all_state = multiworld.get_all_state(False) + for location in locations: + time_taken = self.location_test(location, multiworld.state, "empty_state") + summary_data["empty_state"][location.name] = time_taken + + time_taken = self.location_test(location, all_state, "all_state") + summary_data["all_state"][location.name] = time_taken + + total_empty_state = sum(summary_data["empty_state"].values()) + total_all_state = sum(summary_data["all_state"].values()) + + logger.info(f"{game} took {total_empty_state/len(locations):.4f} " + f"seconds per location in empty_state and {total_all_state/len(locations):.4f} " + f"in all_state. (all times summed for {self.rule_iterations} runs.)") + logger.info(f"Top times in empty_state:\n" + f"{self.format_times_from_counter(summary_data['empty_state'])}") + logger.info(f"Top times in all_state:\n" + f"{self.format_times_from_counter(summary_data['all_state'])}") + + except Exception as e: + logger.exception(e) + + runner = BenchmarkRunner() + runner.main() + + +if __name__ == "__main__": + from path_change import change_home + change_home() + run_locations_benchmark() diff --git a/test/benchmark/path_change.py b/test/benchmark/path_change.py new file mode 100644 index 00000000000..2baa6273e11 --- /dev/null +++ b/test/benchmark/path_change.py @@ -0,0 +1,16 @@ +import sys +import os + + +def change_home(): + """Allow scripts to run from "this" folder.""" + old_home = os.path.dirname(__file__) + sys.path.remove(old_home) + new_home = os.path.normpath(os.path.join(os.path.dirname(__file__), os.pardir, os.pardir)) + os.chdir(new_home) + sys.path.append(new_home) + # fallback to local import + sys.path.append(old_home) + + from Utils import local_path + local_path.cached_path = new_home diff --git a/test/benchmark/time_it.py b/test/benchmark/time_it.py new file mode 100644 index 00000000000..95c0314682f --- /dev/null +++ b/test/benchmark/time_it.py @@ -0,0 +1,23 @@ +import time + + +class TimeIt: + def __init__(self, name: str, time_logger=None): + self.name = name + self.logger = time_logger + self.timer = None + self.end_timer = None + + def __enter__(self): + self.timer = time.perf_counter() + return self + + @property + def dif(self): + return self.end_timer - self.timer + + def __exit__(self, exc_type, exc_val, exc_tb): + if not self.end_timer: + self.end_timer = time.perf_counter() + if self.logger: + self.logger.info(f"{self.dif:.4f} seconds in {self.name}.") diff --git a/worlds/__init__.py b/worlds/__init__.py index 66c91639b9f..168bba7abf4 100644 --- a/worlds/__init__.py +++ b/worlds/__init__.py @@ -3,7 +3,9 @@ import sys import warnings import zipimport -from typing import Dict, List, NamedTuple, TypedDict +import time +import dataclasses +from typing import Dict, List, TypedDict, Optional from Utils import local_path, user_path @@ -34,10 +36,12 @@ class DataPackage(TypedDict): games: Dict[str, GamesPackage] -class WorldSource(NamedTuple): +@dataclasses.dataclass(order=True) +class WorldSource: path: str # typically relative path from this module is_zip: bool = False relative: bool = True # relative to regular world import folder + time_taken: Optional[float] = None def __repr__(self) -> str: return f"{self.__class__.__name__}({self.path}, is_zip={self.is_zip}, relative={self.relative})" @@ -50,6 +54,7 @@ def resolved_path(self) -> str: def load(self) -> bool: try: + start = time.perf_counter() if self.is_zip: importer = zipimport.zipimporter(self.resolved_path) if hasattr(importer, "find_spec"): # new in Python 3.10 @@ -69,6 +74,7 @@ def load(self) -> bool: importer.exec_module(mod) else: importlib.import_module(f".{self.path}", "worlds") + self.time_taken = time.perf_counter()-start return True except Exception: From 281fe01c250015967bc07534320f714d11f3ca95 Mon Sep 17 00:00:00 2001 From: PoryGone <98504756+PoryGone@users.noreply.github.com> Date: Sun, 4 Feb 2024 18:38:00 -0500 Subject: [PATCH 22/38] Core: Purge the evil (`world: MultiWorld`) (#2749) * Purge the evil * Some files didn't save * Fix a couple of missed string references * multi_world -> multiworld --- BaseClasses.py | 10 +- Fill.py | 186 ++++++++-------- Main.py | 286 ++++++++++++------------ OoTAdjuster.py | 8 +- Utils.py | 4 +- test/general/test_fill.py | 350 +++++++++++++++--------------- test/general/test_reachability.py | 18 +- 7 files changed, 431 insertions(+), 431 deletions(-) diff --git a/BaseClasses.py b/BaseClasses.py index 38598d42d99..39f822668c4 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -823,8 +823,8 @@ def __repr__(self): return self.__str__() def __str__(self): - world = self.parent_region.multiworld if self.parent_region else None - return world.get_name_string_for_object(self) if world else f'{self.name} (Player {self.player})' + multiworld = self.parent_region.multiworld if self.parent_region else None + return multiworld.get_name_string_for_object(self) if multiworld else f'{self.name} (Player {self.player})' class Region: @@ -1040,8 +1040,8 @@ def __repr__(self): return self.__str__() def __str__(self): - world = self.parent_region.multiworld if self.parent_region and self.parent_region.multiworld else None - return world.get_name_string_for_object(self) if world else f'{self.name} (Player {self.player})' + multiworld = self.parent_region.multiworld if self.parent_region and self.parent_region.multiworld else None + return multiworld.get_name_string_for_object(self) if multiworld else f'{self.name} (Player {self.player})' def __hash__(self): return hash((self.name, self.player)) @@ -1175,7 +1175,7 @@ def set_entrance(self, entrance: str, exit_: str, direction: str, player: int) - {"player": player, "entrance": entrance, "exit": exit_, "direction": direction} def create_playthrough(self, create_paths: bool = True) -> None: - """Destructive to the world while it is run, damage gets repaired afterwards.""" + """Destructive to the multiworld while it is run, damage gets repaired afterwards.""" from itertools import chain # get locations containing progress items multiworld = self.multiworld diff --git a/Fill.py b/Fill.py index 525d27d3388..97ce4cbdb57 100644 --- a/Fill.py +++ b/Fill.py @@ -27,12 +27,12 @@ def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item] return new_state -def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: typing.List[Location], +def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locations: typing.List[Location], item_pool: typing.List[Item], single_player_placement: bool = False, lock: bool = False, swap: bool = True, on_place: typing.Optional[typing.Callable[[Location], None]] = None, allow_partial: bool = False, allow_excluded: bool = False, name: str = "Unknown") -> None: """ - :param world: Multiworld to be filled. + :param multiworld: Multiworld to be filled. :param base_state: State assumed before fill. :param locations: Locations to be filled with item_pool :param item_pool: Items to fill into the locations @@ -68,7 +68,7 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: maximum_exploration_state = sweep_from_pool( base_state, item_pool + unplaced_items) - has_beaten_game = world.has_beaten_game(maximum_exploration_state) + has_beaten_game = multiworld.has_beaten_game(maximum_exploration_state) while items_to_place: # if we have run out of locations to fill,break out of this loop @@ -80,8 +80,8 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: spot_to_fill: typing.Optional[Location] = None # if minimal accessibility, only check whether location is reachable if game not beatable - if world.worlds[item_to_place.player].options.accessibility == Accessibility.option_minimal: - perform_access_check = not world.has_beaten_game(maximum_exploration_state, + if multiworld.worlds[item_to_place.player].options.accessibility == Accessibility.option_minimal: + perform_access_check = not multiworld.has_beaten_game(maximum_exploration_state, item_to_place.player) \ if single_player_placement else not has_beaten_game else: @@ -122,11 +122,11 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: # Verify placing this item won't reduce available locations, which would be a useless swap. prev_state = swap_state.copy() prev_loc_count = len( - world.get_reachable_locations(prev_state)) + multiworld.get_reachable_locations(prev_state)) swap_state.collect(item_to_place, True) new_loc_count = len( - world.get_reachable_locations(swap_state)) + multiworld.get_reachable_locations(swap_state)) if new_loc_count >= prev_loc_count: # Add this item to the existing placement, and @@ -156,7 +156,7 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: else: unplaced_items.append(item_to_place) continue - world.push_item(spot_to_fill, item_to_place, False) + multiworld.push_item(spot_to_fill, item_to_place, False) spot_to_fill.locked = lock placements.append(spot_to_fill) spot_to_fill.event = item_to_place.advancement @@ -173,7 +173,7 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: # validate all placements and remove invalid ones state = sweep_from_pool(base_state, []) for placement in placements: - if world.accessibility[placement.item.player] != "minimal" and not placement.can_reach(state): + if multiworld.accessibility[placement.item.player] != "minimal" and not placement.can_reach(state): placement.item.location = None unplaced_items.append(placement.item) placement.item = None @@ -188,7 +188,7 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: if excluded_locations: for location in excluded_locations: location.progress_type = location.progress_type.DEFAULT - fill_restrictive(world, base_state, excluded_locations, unplaced_items, single_player_placement, lock, + fill_restrictive(multiworld, base_state, excluded_locations, unplaced_items, single_player_placement, lock, swap, on_place, allow_partial, False) for location in excluded_locations: if not location.item: @@ -196,7 +196,7 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: if not allow_partial and len(unplaced_items) > 0 and len(locations) > 0: # There are leftover unplaceable items and locations that won't accept them - if world.can_beat_game(): + if multiworld.can_beat_game(): logging.warning( f'Not all items placed. Game beatable anyway. (Could not place {unplaced_items})') else: @@ -206,7 +206,7 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: item_pool.extend(unplaced_items) -def remaining_fill(world: MultiWorld, +def remaining_fill(multiworld: MultiWorld, locations: typing.List[Location], itempool: typing.List[Item]) -> None: unplaced_items: typing.List[Item] = [] @@ -261,7 +261,7 @@ def remaining_fill(world: MultiWorld, unplaced_items.append(item_to_place) continue - world.push_item(spot_to_fill, item_to_place, False) + multiworld.push_item(spot_to_fill, item_to_place, False) placements.append(spot_to_fill) placed += 1 if not placed % 1000: @@ -278,19 +278,19 @@ def remaining_fill(world: MultiWorld, itempool.extend(unplaced_items) -def fast_fill(world: MultiWorld, +def fast_fill(multiworld: MultiWorld, item_pool: typing.List[Item], fill_locations: typing.List[Location]) -> typing.Tuple[typing.List[Item], typing.List[Location]]: placing = min(len(item_pool), len(fill_locations)) for item, location in zip(item_pool, fill_locations): - world.push_item(location, item, False) + multiworld.push_item(location, item, False) return item_pool[placing:], fill_locations[placing:] -def accessibility_corrections(world: MultiWorld, state: CollectionState, locations, pool=[]): +def accessibility_corrections(multiworld: MultiWorld, state: CollectionState, locations, pool=[]): maximum_exploration_state = sweep_from_pool(state, pool) - minimal_players = {player for player in world.player_ids if world.worlds[player].options.accessibility == "minimal"} - unreachable_locations = [location for location in world.get_locations() if location.player in minimal_players and + minimal_players = {player for player in multiworld.player_ids if multiworld.worlds[player].options.accessibility == "minimal"} + unreachable_locations = [location for location in multiworld.get_locations() if location.player in minimal_players and not location.can_reach(maximum_exploration_state)] for location in unreachable_locations: if (location.item is not None and location.item.advancement and location.address is not None and not @@ -304,36 +304,36 @@ def accessibility_corrections(world: MultiWorld, state: CollectionState, locatio locations.append(location) if pool and locations: locations.sort(key=lambda loc: loc.progress_type != LocationProgressType.PRIORITY) - fill_restrictive(world, state, locations, pool, name="Accessibility Corrections") + fill_restrictive(multiworld, state, locations, pool, name="Accessibility Corrections") -def inaccessible_location_rules(world: MultiWorld, state: CollectionState, locations): +def inaccessible_location_rules(multiworld: MultiWorld, state: CollectionState, locations): maximum_exploration_state = sweep_from_pool(state) unreachable_locations = [location for location in locations if not location.can_reach(maximum_exploration_state)] if unreachable_locations: def forbid_important_item_rule(item: Item): - return not ((item.classification & 0b0011) and world.worlds[item.player].options.accessibility != 'minimal') + return not ((item.classification & 0b0011) and multiworld.worlds[item.player].options.accessibility != 'minimal') for location in unreachable_locations: add_item_rule(location, forbid_important_item_rule) -def distribute_early_items(world: MultiWorld, +def distribute_early_items(multiworld: MultiWorld, fill_locations: typing.List[Location], itempool: typing.List[Item]) -> typing.Tuple[typing.List[Location], typing.List[Item]]: """ returns new fill_locations and itempool """ early_items_count: typing.Dict[typing.Tuple[str, int], typing.List[int]] = {} - for player in world.player_ids: - items = itertools.chain(world.early_items[player], world.local_early_items[player]) + for player in multiworld.player_ids: + items = itertools.chain(multiworld.early_items[player], multiworld.local_early_items[player]) for item in items: - early_items_count[item, player] = [world.early_items[player].get(item, 0), - world.local_early_items[player].get(item, 0)] + early_items_count[item, player] = [multiworld.early_items[player].get(item, 0), + multiworld.local_early_items[player].get(item, 0)] if early_items_count: early_locations: typing.List[Location] = [] early_priority_locations: typing.List[Location] = [] loc_indexes_to_remove: typing.Set[int] = set() - base_state = world.state.copy() - base_state.sweep_for_events(locations=(loc for loc in world.get_filled_locations() if loc.address is None)) + base_state = multiworld.state.copy() + base_state.sweep_for_events(locations=(loc for loc in multiworld.get_filled_locations() if loc.address is None)) for i, loc in enumerate(fill_locations): if loc.can_reach(base_state): if loc.progress_type == LocationProgressType.PRIORITY: @@ -345,8 +345,8 @@ def distribute_early_items(world: MultiWorld, early_prog_items: typing.List[Item] = [] early_rest_items: typing.List[Item] = [] - early_local_prog_items: typing.Dict[int, typing.List[Item]] = {player: [] for player in world.player_ids} - early_local_rest_items: typing.Dict[int, typing.List[Item]] = {player: [] for player in world.player_ids} + early_local_prog_items: typing.Dict[int, typing.List[Item]] = {player: [] for player in multiworld.player_ids} + early_local_rest_items: typing.Dict[int, typing.List[Item]] = {player: [] for player in multiworld.player_ids} item_indexes_to_remove: typing.Set[int] = set() for i, item in enumerate(itempool): if (item.name, item.player) in early_items_count: @@ -370,28 +370,28 @@ def distribute_early_items(world: MultiWorld, if len(early_items_count) == 0: break itempool = [item for i, item in enumerate(itempool) if i not in item_indexes_to_remove] - for player in world.player_ids: + for player in multiworld.player_ids: player_local = early_local_rest_items[player] - fill_restrictive(world, base_state, + fill_restrictive(multiworld, base_state, [loc for loc in early_locations if loc.player == player], player_local, lock=True, allow_partial=True, name=f"Local Early Items P{player}") if player_local: logging.warning(f"Could not fulfill rules of early items: {player_local}") early_rest_items.extend(early_local_rest_items[player]) early_locations = [loc for loc in early_locations if not loc.item] - fill_restrictive(world, base_state, early_locations, early_rest_items, lock=True, allow_partial=True, + fill_restrictive(multiworld, base_state, early_locations, early_rest_items, lock=True, allow_partial=True, name="Early Items") early_locations += early_priority_locations - for player in world.player_ids: + for player in multiworld.player_ids: player_local = early_local_prog_items[player] - fill_restrictive(world, base_state, + fill_restrictive(multiworld, base_state, [loc for loc in early_locations if loc.player == player], player_local, lock=True, allow_partial=True, name=f"Local Early Progression P{player}") if player_local: logging.warning(f"Could not fulfill rules of early items: {player_local}") early_prog_items.extend(player_local) early_locations = [loc for loc in early_locations if not loc.item] - fill_restrictive(world, base_state, early_locations, early_prog_items, lock=True, allow_partial=True, + fill_restrictive(multiworld, base_state, early_locations, early_prog_items, lock=True, allow_partial=True, name="Early Progression") unplaced_early_items = early_rest_items + early_prog_items if unplaced_early_items: @@ -400,18 +400,18 @@ def distribute_early_items(world: MultiWorld, itempool += unplaced_early_items fill_locations.extend(early_locations) - world.random.shuffle(fill_locations) + multiworld.random.shuffle(fill_locations) return fill_locations, itempool -def distribute_items_restrictive(world: MultiWorld) -> None: - fill_locations = sorted(world.get_unfilled_locations()) - world.random.shuffle(fill_locations) +def distribute_items_restrictive(multiworld: MultiWorld) -> None: + fill_locations = sorted(multiworld.get_unfilled_locations()) + multiworld.random.shuffle(fill_locations) # get items to distribute - itempool = sorted(world.itempool) - world.random.shuffle(itempool) + itempool = sorted(multiworld.itempool) + multiworld.random.shuffle(itempool) - fill_locations, itempool = distribute_early_items(world, fill_locations, itempool) + fill_locations, itempool = distribute_early_items(multiworld, fill_locations, itempool) progitempool: typing.List[Item] = [] usefulitempool: typing.List[Item] = [] @@ -425,7 +425,7 @@ def distribute_items_restrictive(world: MultiWorld) -> None: else: filleritempool.append(item) - call_all(world, "fill_hook", progitempool, usefulitempool, filleritempool, fill_locations) + call_all(multiworld, "fill_hook", progitempool, usefulitempool, filleritempool, fill_locations) locations: typing.Dict[LocationProgressType, typing.List[Location]] = { loc_type: [] for loc_type in LocationProgressType} @@ -446,34 +446,34 @@ def mark_for_locking(location: Location): if prioritylocations: # "priority fill" - fill_restrictive(world, world.state, prioritylocations, progitempool, swap=False, on_place=mark_for_locking, + fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool, swap=False, on_place=mark_for_locking, name="Priority") - accessibility_corrections(world, world.state, prioritylocations, progitempool) + accessibility_corrections(multiworld, multiworld.state, prioritylocations, progitempool) defaultlocations = prioritylocations + defaultlocations if progitempool: # "advancement/progression fill" - fill_restrictive(world, world.state, defaultlocations, progitempool, name="Progression") + fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, name="Progression") if progitempool: raise FillError( f'Not enough locations for progress items. There are {len(progitempool)} more items than locations') - accessibility_corrections(world, world.state, defaultlocations) + accessibility_corrections(multiworld, multiworld.state, defaultlocations) for location in lock_later: if location.item: location.locked = True del mark_for_locking, lock_later - inaccessible_location_rules(world, world.state, defaultlocations) + inaccessible_location_rules(multiworld, multiworld.state, defaultlocations) - remaining_fill(world, excludedlocations, filleritempool) + remaining_fill(multiworld, excludedlocations, filleritempool) if excludedlocations: raise FillError( f"Not enough filler items for excluded locations. There are {len(excludedlocations)} more locations than items") restitempool = filleritempool + usefulitempool - remaining_fill(world, defaultlocations, restitempool) + remaining_fill(multiworld, defaultlocations, restitempool) unplaced = restitempool unfilled = defaultlocations @@ -481,40 +481,40 @@ def mark_for_locking(location: Location): if unplaced or unfilled: logging.warning( f'Unplaced items({len(unplaced)}): {unplaced} - Unfilled Locations({len(unfilled)}): {unfilled}') - items_counter = Counter(location.item.player for location in world.get_locations() if location.item) - locations_counter = Counter(location.player for location in world.get_locations()) + items_counter = Counter(location.item.player for location in multiworld.get_locations() if location.item) + locations_counter = Counter(location.player for location in multiworld.get_locations()) items_counter.update(item.player for item in unplaced) locations_counter.update(location.player for location in unfilled) print_data = {"items": items_counter, "locations": locations_counter} logging.info(f'Per-Player counts: {print_data})') -def flood_items(world: MultiWorld) -> None: +def flood_items(multiworld: MultiWorld) -> None: # get items to distribute - world.random.shuffle(world.itempool) - itempool = world.itempool + multiworld.random.shuffle(multiworld.itempool) + itempool = multiworld.itempool progress_done = False # sweep once to pick up preplaced items - world.state.sweep_for_events() + multiworld.state.sweep_for_events() - # fill world from top of itempool while we can + # fill multiworld from top of itempool while we can while not progress_done: - location_list = world.get_unfilled_locations() - world.random.shuffle(location_list) + location_list = multiworld.get_unfilled_locations() + multiworld.random.shuffle(location_list) spot_to_fill = None for location in location_list: - if location.can_fill(world.state, itempool[0]): + if location.can_fill(multiworld.state, itempool[0]): spot_to_fill = location break if spot_to_fill: item = itempool.pop(0) - world.push_item(spot_to_fill, item, True) + multiworld.push_item(spot_to_fill, item, True) continue # ran out of spots, check if we need to step in and correct things - if len(world.get_reachable_locations()) == len(world.get_locations()): + if len(multiworld.get_reachable_locations()) == len(multiworld.get_locations()): progress_done = True continue @@ -524,7 +524,7 @@ def flood_items(world: MultiWorld) -> None: for item in itempool: if item.advancement: candidate_item_to_place = item - if world.unlocks_new_location(item): + if multiworld.unlocks_new_location(item): item_to_place = item break @@ -537,15 +537,15 @@ def flood_items(world: MultiWorld) -> None: raise FillError('No more progress items left to place.') # find item to replace with progress item - location_list = world.get_reachable_locations() - world.random.shuffle(location_list) + location_list = multiworld.get_reachable_locations() + multiworld.random.shuffle(location_list) for location in location_list: if location.item is not None and not location.item.advancement: # safe to replace replace_item = location.item replace_item.location = None itempool.append(replace_item) - world.push_item(location, item_to_place, True) + multiworld.push_item(location, item_to_place, True) itempool.remove(item_to_place) break @@ -755,7 +755,7 @@ def swap_location_item(location_1: Location, location_2: Location, check_locked: location_1.event, location_2.event = location_2.event, location_1.event -def distribute_planned(world: MultiWorld) -> None: +def distribute_planned(multiworld: MultiWorld) -> None: def warn(warning: str, force: typing.Union[bool, str]) -> None: if force in [True, 'fail', 'failure', 'none', False, 'warn', 'warning']: logging.warning(f'{warning}') @@ -768,24 +768,24 @@ def failed(warning: str, force: typing.Union[bool, str]) -> None: else: warn(warning, force) - swept_state = world.state.copy() + swept_state = multiworld.state.copy() swept_state.sweep_for_events() - reachable = frozenset(world.get_reachable_locations(swept_state)) + reachable = frozenset(multiworld.get_reachable_locations(swept_state)) early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list) non_early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list) - for loc in world.get_unfilled_locations(): + for loc in multiworld.get_unfilled_locations(): if loc in reachable: early_locations[loc.player].append(loc.name) else: # not reachable with swept state non_early_locations[loc.player].append(loc.name) - world_name_lookup = world.world_name_lookup + world_name_lookup = multiworld.world_name_lookup block_value = typing.Union[typing.List[str], typing.Dict[str, typing.Any], str] plando_blocks: typing.List[typing.Dict[str, typing.Any]] = [] - player_ids = set(world.player_ids) + player_ids = set(multiworld.player_ids) for player in player_ids: - for block in world.plando_items[player]: + for block in multiworld.plando_items[player]: block['player'] = player if 'force' not in block: block['force'] = 'silent' @@ -799,12 +799,12 @@ def failed(warning: str, force: typing.Union[bool, str]) -> None: else: target_world = block['world'] - if target_world is False or world.players == 1: # target own world + if target_world is False or multiworld.players == 1: # target own world worlds: typing.Set[int] = {player} elif target_world is True: # target any worlds besides own - worlds = set(world.player_ids) - {player} + worlds = set(multiworld.player_ids) - {player} elif target_world is None: # target all worlds - worlds = set(world.player_ids) + worlds = set(multiworld.player_ids) elif type(target_world) == list: # list of target worlds worlds = set() for listed_world in target_world: @@ -814,9 +814,9 @@ def failed(warning: str, force: typing.Union[bool, str]) -> None: continue worlds.add(world_name_lookup[listed_world]) elif type(target_world) == int: # target world by slot number - if target_world not in range(1, world.players + 1): + if target_world not in range(1, multiworld.players + 1): failed( - f"Cannot place item in world {target_world} as it is not in range of (1, {world.players})", + f"Cannot place item in world {target_world} as it is not in range of (1, {multiworld.players})", block['force']) continue worlds = {target_world} @@ -844,7 +844,7 @@ def failed(warning: str, force: typing.Union[bool, str]) -> None: item_list: typing.List[str] = [] for key, value in items.items(): if value is True: - value = world.itempool.count(world.worlds[player].create_item(key)) + value = multiworld.itempool.count(multiworld.worlds[player].create_item(key)) item_list += [key] * value items = item_list if isinstance(items, str): @@ -894,17 +894,17 @@ def failed(warning: str, force: typing.Union[bool, str]) -> None: count = block['count'] failed(f"Plando count {count} greater than locations specified", block['force']) block['count'] = len(block['locations']) - block['count']['target'] = world.random.randint(block['count']['min'], block['count']['max']) + block['count']['target'] = multiworld.random.randint(block['count']['min'], block['count']['max']) if block['count']['target'] > 0: plando_blocks.append(block) # shuffle, but then sort blocks by number of locations minus number of items, # so less-flexible blocks get priority - world.random.shuffle(plando_blocks) + multiworld.random.shuffle(plando_blocks) plando_blocks.sort(key=lambda block: (len(block['locations']) - block['count']['target'] if len(block['locations']) > 0 - else len(world.get_unfilled_locations(player)) - block['count']['target'])) + else len(multiworld.get_unfilled_locations(player)) - block['count']['target'])) for placement in plando_blocks: player = placement['player'] @@ -915,19 +915,19 @@ def failed(warning: str, force: typing.Union[bool, str]) -> None: maxcount = placement['count']['target'] from_pool = placement['from_pool'] - candidates = list(world.get_unfilled_locations_for_players(locations, sorted(worlds))) - world.random.shuffle(candidates) - world.random.shuffle(items) + candidates = list(multiworld.get_unfilled_locations_for_players(locations, sorted(worlds))) + multiworld.random.shuffle(candidates) + multiworld.random.shuffle(items) count = 0 err: typing.List[str] = [] successful_pairs: typing.List[typing.Tuple[Item, Location]] = [] for item_name in items: - item = world.worlds[player].create_item(item_name) + item = multiworld.worlds[player].create_item(item_name) for location in reversed(candidates): if (location.address is None) == (item.code is None): # either both None or both not None if not location.item: if location.item_rule(item): - if location.can_fill(world.state, item, False): + if location.can_fill(multiworld.state, item, False): successful_pairs.append((item, location)) candidates.remove(location) count = count + 1 @@ -945,21 +945,21 @@ def failed(warning: str, force: typing.Union[bool, str]) -> None: if count < placement['count']['min']: m = placement['count']['min'] failed( - f"Plando block failed to place {m - count} of {m} item(s) for {world.player_name[player]}, error(s): {' '.join(err)}", + f"Plando block failed to place {m - count} of {m} item(s) for {multiworld.player_name[player]}, error(s): {' '.join(err)}", placement['force']) for (item, location) in successful_pairs: - world.push_item(location, item, collect=False) + multiworld.push_item(location, item, collect=False) location.event = True # flag location to be checked during fill location.locked = True logging.debug(f"Plando placed {item} at {location}") if from_pool: try: - world.itempool.remove(item) + multiworld.itempool.remove(item) except ValueError: warn( - f"Could not remove {item} from pool for {world.player_name[player]} as it's already missing from it.", + f"Could not remove {item} from pool for {multiworld.player_name[player]} as it's already missing from it.", placement['force']) except Exception as e: raise Exception( - f"Error running plando for player {player} ({world.player_name[player]})") from e + f"Error running plando for player {player} ({multiworld.player_name[player]})") from e diff --git a/Main.py b/Main.py index e49d8e781df..f1d2f63692d 100644 --- a/Main.py +++ b/Main.py @@ -30,49 +30,49 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No output_path.cached_path = args.outputpath start = time.perf_counter() - # initialize the world - world = MultiWorld(args.multi) + # initialize the multiworld + multiworld = MultiWorld(args.multi) logger = logging.getLogger() - world.set_seed(seed, args.race, str(args.outputname) if args.outputname else None) - world.plando_options = args.plando_options - - world.shuffle = args.shuffle.copy() - world.logic = args.logic.copy() - world.mode = args.mode.copy() - world.difficulty = args.difficulty.copy() - world.item_functionality = args.item_functionality.copy() - world.timer = args.timer.copy() - world.goal = args.goal.copy() - world.boss_shuffle = args.shufflebosses.copy() - world.enemy_health = args.enemy_health.copy() - world.enemy_damage = args.enemy_damage.copy() - world.beemizer_total_chance = args.beemizer_total_chance.copy() - world.beemizer_trap_chance = args.beemizer_trap_chance.copy() - world.countdown_start_time = args.countdown_start_time.copy() - world.red_clock_time = args.red_clock_time.copy() - world.blue_clock_time = args.blue_clock_time.copy() - world.green_clock_time = args.green_clock_time.copy() - world.dungeon_counters = args.dungeon_counters.copy() - world.triforce_pieces_available = args.triforce_pieces_available.copy() - world.triforce_pieces_required = args.triforce_pieces_required.copy() - world.shop_shuffle = args.shop_shuffle.copy() - world.shuffle_prizes = args.shuffle_prizes.copy() - world.sprite_pool = args.sprite_pool.copy() - world.dark_room_logic = args.dark_room_logic.copy() - world.plando_items = args.plando_items.copy() - world.plando_texts = args.plando_texts.copy() - world.plando_connections = args.plando_connections.copy() - world.required_medallions = args.required_medallions.copy() - world.game = args.game.copy() - world.player_name = args.name.copy() - world.sprite = args.sprite.copy() - world.glitch_triforce = args.glitch_triforce # This is enabled/disabled globally, no per player option. - - world.set_options(args) - world.set_item_links() - world.state = CollectionState(world) - logger.info('Archipelago Version %s - Seed: %s\n', __version__, world.seed) + multiworld.set_seed(seed, args.race, str(args.outputname) if args.outputname else None) + multiworld.plando_options = args.plando_options + + multiworld.shuffle = args.shuffle.copy() + multiworld.logic = args.logic.copy() + multiworld.mode = args.mode.copy() + multiworld.difficulty = args.difficulty.copy() + multiworld.item_functionality = args.item_functionality.copy() + multiworld.timer = args.timer.copy() + multiworld.goal = args.goal.copy() + multiworld.boss_shuffle = args.shufflebosses.copy() + multiworld.enemy_health = args.enemy_health.copy() + multiworld.enemy_damage = args.enemy_damage.copy() + multiworld.beemizer_total_chance = args.beemizer_total_chance.copy() + multiworld.beemizer_trap_chance = args.beemizer_trap_chance.copy() + multiworld.countdown_start_time = args.countdown_start_time.copy() + multiworld.red_clock_time = args.red_clock_time.copy() + multiworld.blue_clock_time = args.blue_clock_time.copy() + multiworld.green_clock_time = args.green_clock_time.copy() + multiworld.dungeon_counters = args.dungeon_counters.copy() + multiworld.triforce_pieces_available = args.triforce_pieces_available.copy() + multiworld.triforce_pieces_required = args.triforce_pieces_required.copy() + multiworld.shop_shuffle = args.shop_shuffle.copy() + multiworld.shuffle_prizes = args.shuffle_prizes.copy() + multiworld.sprite_pool = args.sprite_pool.copy() + multiworld.dark_room_logic = args.dark_room_logic.copy() + multiworld.plando_items = args.plando_items.copy() + multiworld.plando_texts = args.plando_texts.copy() + multiworld.plando_connections = args.plando_connections.copy() + multiworld.required_medallions = args.required_medallions.copy() + multiworld.game = args.game.copy() + multiworld.player_name = args.name.copy() + multiworld.sprite = args.sprite.copy() + multiworld.glitch_triforce = args.glitch_triforce # This is enabled/disabled globally, no per player option. + + multiworld.set_options(args) + multiworld.set_item_links() + multiworld.state = CollectionState(multiworld) + logger.info('Archipelago Version %s - Seed: %s\n', __version__, multiworld.seed) logger.info(f"Found {len(AutoWorld.AutoWorldRegister.world_types)} World Types:") longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types) @@ -103,93 +103,93 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No # This assertion method should not be necessary to run if we are not outputting any multidata. if not args.skip_output: - AutoWorld.call_stage(world, "assert_generate") + AutoWorld.call_stage(multiworld, "assert_generate") - AutoWorld.call_all(world, "generate_early") + AutoWorld.call_all(multiworld, "generate_early") logger.info('') - for player in world.player_ids: - for item_name, count in world.worlds[player].options.start_inventory.value.items(): + for player in multiworld.player_ids: + for item_name, count in multiworld.worlds[player].options.start_inventory.value.items(): for _ in range(count): - world.push_precollected(world.create_item(item_name, player)) + multiworld.push_precollected(multiworld.create_item(item_name, player)) - for item_name, count in getattr(world.worlds[player].options, + for item_name, count in getattr(multiworld.worlds[player].options, "start_inventory_from_pool", StartInventoryPool({})).value.items(): for _ in range(count): - world.push_precollected(world.create_item(item_name, player)) + multiworld.push_precollected(multiworld.create_item(item_name, player)) # remove from_pool items also from early items handling, as starting is plenty early. - early = world.early_items[player].get(item_name, 0) + early = multiworld.early_items[player].get(item_name, 0) if early: - world.early_items[player][item_name] = max(0, early-count) + multiworld.early_items[player][item_name] = max(0, early-count) remaining_count = count-early if remaining_count > 0: - local_early = world.early_local_items[player].get(item_name, 0) + local_early = multiworld.early_local_items[player].get(item_name, 0) if local_early: - world.early_items[player][item_name] = max(0, local_early - remaining_count) + multiworld.early_items[player][item_name] = max(0, local_early - remaining_count) del local_early del early - logger.info('Creating World.') - AutoWorld.call_all(world, "create_regions") + logger.info('Creating MultiWorld.') + AutoWorld.call_all(multiworld, "create_regions") logger.info('Creating Items.') - AutoWorld.call_all(world, "create_items") + AutoWorld.call_all(multiworld, "create_items") logger.info('Calculating Access Rules.') - for player in world.player_ids: + for player in multiworld.player_ids: # items can't be both local and non-local, prefer local - world.worlds[player].options.non_local_items.value -= world.worlds[player].options.local_items.value - world.worlds[player].options.non_local_items.value -= set(world.local_early_items[player]) + multiworld.worlds[player].options.non_local_items.value -= multiworld.worlds[player].options.local_items.value + multiworld.worlds[player].options.non_local_items.value -= set(multiworld.local_early_items[player]) - AutoWorld.call_all(world, "set_rules") + AutoWorld.call_all(multiworld, "set_rules") - for player in world.player_ids: - exclusion_rules(world, player, world.worlds[player].options.exclude_locations.value) - world.worlds[player].options.priority_locations.value -= world.worlds[player].options.exclude_locations.value - for location_name in world.worlds[player].options.priority_locations.value: + for player in multiworld.player_ids: + exclusion_rules(multiworld, player, multiworld.worlds[player].options.exclude_locations.value) + multiworld.worlds[player].options.priority_locations.value -= multiworld.worlds[player].options.exclude_locations.value + for location_name in multiworld.worlds[player].options.priority_locations.value: try: - location = world.get_location(location_name, player) + location = multiworld.get_location(location_name, player) except KeyError as e: # failed to find the given location. Check if it's a legitimate location - if location_name not in world.worlds[player].location_name_to_id: + if location_name not in multiworld.worlds[player].location_name_to_id: raise Exception(f"Unable to prioritize location {location_name} in player {player}'s world.") from e else: location.progress_type = LocationProgressType.PRIORITY # Set local and non-local item rules. - if world.players > 1: - locality_rules(world) + if multiworld.players > 1: + locality_rules(multiworld) else: - world.worlds[1].options.non_local_items.value = set() - world.worlds[1].options.local_items.value = set() + multiworld.worlds[1].options.non_local_items.value = set() + multiworld.worlds[1].options.local_items.value = set() - AutoWorld.call_all(world, "generate_basic") + AutoWorld.call_all(multiworld, "generate_basic") # remove starting inventory from pool items. # Because some worlds don't actually create items during create_items this has to be as late as possible. - if any(getattr(world.worlds[player].options, "start_inventory_from_pool", None) for player in world.player_ids): + if any(getattr(multiworld.worlds[player].options, "start_inventory_from_pool", None) for player in multiworld.player_ids): new_items: List[Item] = [] depletion_pool: Dict[int, Dict[str, int]] = { - player: getattr(world.worlds[player].options, + player: getattr(multiworld.worlds[player].options, "start_inventory_from_pool", StartInventoryPool({})).value.copy() - for player in world.player_ids + for player in multiworld.player_ids } for player, items in depletion_pool.items(): - player_world: AutoWorld.World = world.worlds[player] + player_world: AutoWorld.World = multiworld.worlds[player] for count in items.values(): for _ in range(count): new_items.append(player_world.create_filler()) target: int = sum(sum(items.values()) for items in depletion_pool.values()) - for i, item in enumerate(world.itempool): + for i, item in enumerate(multiworld.itempool): if depletion_pool[item.player].get(item.name, 0): target -= 1 depletion_pool[item.player][item.name] -= 1 # quick abort if we have found all items if not target: - new_items.extend(world.itempool[i+1:]) + new_items.extend(multiworld.itempool[i+1:]) break else: new_items.append(item) @@ -199,19 +199,19 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No for player, remaining_items in depletion_pool.items(): remaining_items = {name: count for name, count in remaining_items.items() if count} if remaining_items: - raise Exception(f"{world.get_player_name(player)}" + raise Exception(f"{multiworld.get_player_name(player)}" f" is trying to remove items from their pool that don't exist: {remaining_items}") - assert len(world.itempool) == len(new_items), "Item Pool amounts should not change." - world.itempool[:] = new_items + assert len(multiworld.itempool) == len(new_items), "Item Pool amounts should not change." + multiworld.itempool[:] = new_items # temporary home for item links, should be moved out of Main - for group_id, group in world.groups.items(): + for group_id, group in multiworld.groups.items(): def find_common_pool(players: Set[int], shared_pool: Set[str]) -> Tuple[ Optional[Dict[int, Dict[str, int]]], Optional[Dict[str, int]] ]: classifications: Dict[str, int] = collections.defaultdict(int) counters = {player: {name: 0 for name in shared_pool} for player in players} - for item in world.itempool: + for item in multiworld.itempool: if item.player in counters and item.name in shared_pool: counters[item.player][item.name] += 1 classifications[item.name] |= item.classification @@ -246,13 +246,13 @@ def find_common_pool(players: Set[int], shared_pool: Set[str]) -> Tuple[ new_item.classification |= classifications[item_name] new_itempool.append(new_item) - region = Region("Menu", group_id, world, "ItemLink") - world.regions.append(region) + region = Region("Menu", group_id, multiworld, "ItemLink") + multiworld.regions.append(region) locations = region.locations - for item in world.itempool: + for item in multiworld.itempool: count = common_item_count.get(item.player, {}).get(item.name, 0) if count: - loc = Location(group_id, f"Item Link: {item.name} -> {world.player_name[item.player]} {count}", + loc = Location(group_id, f"Item Link: {item.name} -> {multiworld.player_name[item.player]} {count}", None, region) loc.access_rule = lambda state, item_name = item.name, group_id_ = group_id, count_ = count: \ state.has(item_name, group_id_, count_) @@ -263,10 +263,10 @@ def find_common_pool(players: Set[int], shared_pool: Set[str]) -> Tuple[ else: new_itempool.append(item) - itemcount = len(world.itempool) - world.itempool = new_itempool + itemcount = len(multiworld.itempool) + multiworld.itempool = new_itempool - while itemcount > len(world.itempool): + while itemcount > len(multiworld.itempool): items_to_add = [] for player in group["players"]: if group["link_replacement"]: @@ -274,64 +274,64 @@ def find_common_pool(players: Set[int], shared_pool: Set[str]) -> Tuple[ else: item_player = player if group["replacement_items"][player]: - items_to_add.append(AutoWorld.call_single(world, "create_item", item_player, + items_to_add.append(AutoWorld.call_single(multiworld, "create_item", item_player, group["replacement_items"][player])) else: - items_to_add.append(AutoWorld.call_single(world, "create_filler", item_player)) - world.random.shuffle(items_to_add) - world.itempool.extend(items_to_add[:itemcount - len(world.itempool)]) + items_to_add.append(AutoWorld.call_single(multiworld, "create_filler", item_player)) + multiworld.random.shuffle(items_to_add) + multiworld.itempool.extend(items_to_add[:itemcount - len(multiworld.itempool)]) - if any(world.item_links.values()): - world._all_state = None + if any(multiworld.item_links.values()): + multiworld._all_state = None logger.info("Running Item Plando.") - distribute_planned(world) + distribute_planned(multiworld) logger.info('Running Pre Main Fill.') - AutoWorld.call_all(world, "pre_fill") + AutoWorld.call_all(multiworld, "pre_fill") - logger.info(f'Filling the world with {len(world.itempool)} items.') + logger.info(f'Filling the multiworld with {len(multiworld.itempool)} items.') - if world.algorithm == 'flood': - flood_items(world) # different algo, biased towards early game progress items - elif world.algorithm == 'balanced': - distribute_items_restrictive(world) + if multiworld.algorithm == 'flood': + flood_items(multiworld) # different algo, biased towards early game progress items + elif multiworld.algorithm == 'balanced': + distribute_items_restrictive(multiworld) - AutoWorld.call_all(world, 'post_fill') + AutoWorld.call_all(multiworld, 'post_fill') - if world.players > 1 and not args.skip_prog_balancing: - balance_multiworld_progression(world) + if multiworld.players > 1 and not args.skip_prog_balancing: + balance_multiworld_progression(multiworld) else: logger.info("Progression balancing skipped.") # we're about to output using multithreading, so we're removing the global random state to prevent accidental use - world.random.passthrough = False + multiworld.random.passthrough = False if args.skip_output: logger.info('Done. Skipped output/spoiler generation. Total Time: %s', time.perf_counter() - start) - return world + return multiworld logger.info(f'Beginning output...') - outfilebase = 'AP_' + world.seed_name + outfilebase = 'AP_' + multiworld.seed_name output = tempfile.TemporaryDirectory() with output as temp_dir: - output_players = [player for player in world.player_ids if AutoWorld.World.generate_output.__code__ - is not world.worlds[player].generate_output.__code__] + output_players = [player for player in multiworld.player_ids if AutoWorld.World.generate_output.__code__ + is not multiworld.worlds[player].generate_output.__code__] with concurrent.futures.ThreadPoolExecutor(len(output_players) + 2) as pool: - check_accessibility_task = pool.submit(world.fulfills_accessibility) + check_accessibility_task = pool.submit(multiworld.fulfills_accessibility) - output_file_futures = [pool.submit(AutoWorld.call_stage, world, "generate_output", temp_dir)] + output_file_futures = [pool.submit(AutoWorld.call_stage, multiworld, "generate_output", temp_dir)] for player in output_players: # skip starting a thread for methods that say "pass". output_file_futures.append( - pool.submit(AutoWorld.call_single, world, "generate_output", player, temp_dir)) + pool.submit(AutoWorld.call_single, multiworld, "generate_output", player, temp_dir)) # collect ER hint info er_hint_data: Dict[int, Dict[int, str]] = {} - AutoWorld.call_all(world, 'extend_hint_information', er_hint_data) + AutoWorld.call_all(multiworld, 'extend_hint_information', er_hint_data) def write_multidata(): import NetUtils @@ -340,38 +340,38 @@ def write_multidata(): games = {} minimum_versions = {"server": AutoWorld.World.required_server_version, "clients": client_versions} slot_info = {} - names = [[name for player, name in sorted(world.player_name.items())]] - for slot in world.player_ids: - player_world: AutoWorld.World = world.worlds[slot] + names = [[name for player, name in sorted(multiworld.player_name.items())]] + for slot in multiworld.player_ids: + player_world: AutoWorld.World = multiworld.worlds[slot] minimum_versions["server"] = max(minimum_versions["server"], player_world.required_server_version) client_versions[slot] = player_world.required_client_version - games[slot] = world.game[slot] - slot_info[slot] = NetUtils.NetworkSlot(names[0][slot - 1], world.game[slot], - world.player_types[slot]) - for slot, group in world.groups.items(): - games[slot] = world.game[slot] - slot_info[slot] = NetUtils.NetworkSlot(group["name"], world.game[slot], world.player_types[slot], + games[slot] = multiworld.game[slot] + slot_info[slot] = NetUtils.NetworkSlot(names[0][slot - 1], multiworld.game[slot], + multiworld.player_types[slot]) + for slot, group in multiworld.groups.items(): + games[slot] = multiworld.game[slot] + slot_info[slot] = NetUtils.NetworkSlot(group["name"], multiworld.game[slot], multiworld.player_types[slot], group_members=sorted(group["players"])) precollected_items = {player: [item.code for item in world_precollected if type(item.code) == int] - for player, world_precollected in world.precollected_items.items()} - precollected_hints = {player: set() for player in range(1, world.players + 1 + len(world.groups))} + for player, world_precollected in multiworld.precollected_items.items()} + precollected_hints = {player: set() for player in range(1, multiworld.players + 1 + len(multiworld.groups))} - for slot in world.player_ids: - slot_data[slot] = world.worlds[slot].fill_slot_data() + for slot in multiworld.player_ids: + slot_data[slot] = multiworld.worlds[slot].fill_slot_data() def precollect_hint(location): entrance = er_hint_data.get(location.player, {}).get(location.address, "") hint = NetUtils.Hint(location.item.player, location.player, location.address, location.item.code, False, entrance, location.item.flags) precollected_hints[location.player].add(hint) - if location.item.player not in world.groups: + if location.item.player not in multiworld.groups: precollected_hints[location.item.player].add(hint) else: - for player in world.groups[location.item.player]["players"]: + for player in multiworld.groups[location.item.player]["players"]: precollected_hints[player].add(hint) - locations_data: Dict[int, Dict[int, Tuple[int, int, int]]] = {player: {} for player in world.player_ids} - for location in world.get_filled_locations(): + locations_data: Dict[int, Dict[int, Tuple[int, int, int]]] = {player: {} for player in multiworld.player_ids} + for location in multiworld.get_filled_locations(): if type(location.address) == int: assert location.item.code is not None, "item code None should be event, " \ "location.address should then also be None. Location: " \ @@ -381,18 +381,18 @@ def precollect_hint(location): f"{locations_data[location.player][location.address]}") locations_data[location.player][location.address] = \ location.item.code, location.item.player, location.item.flags - if location.name in world.worlds[location.player].options.start_location_hints: + if location.name in multiworld.worlds[location.player].options.start_location_hints: precollect_hint(location) - elif location.item.name in world.worlds[location.item.player].options.start_hints: + elif location.item.name in multiworld.worlds[location.item.player].options.start_hints: precollect_hint(location) - elif any([location.item.name in world.worlds[player].options.start_hints - for player in world.groups.get(location.item.player, {}).get("players", [])]): + elif any([location.item.name in multiworld.worlds[player].options.start_hints + for player in multiworld.groups.get(location.item.player, {}).get("players", [])]): precollect_hint(location) # embedded data package data_package = { game_world.game: worlds.network_data_package["games"][game_world.game] - for game_world in world.worlds.values() + for game_world in multiworld.worlds.values() } checks_in_area: Dict[int, Dict[str, Union[int, List[int]]]] = {} @@ -400,7 +400,7 @@ def precollect_hint(location): multidata = { "slot_data": slot_data, "slot_info": slot_info, - "connect_names": {name: (0, player) for player, name in world.player_name.items()}, + "connect_names": {name: (0, player) for player, name in multiworld.player_name.items()}, "locations": locations_data, "checks_in_area": checks_in_area, "server_options": baked_server_options, @@ -410,10 +410,10 @@ def precollect_hint(location): "version": tuple(version_tuple), "tags": ["AP"], "minimum_versions": minimum_versions, - "seed_name": world.seed_name, + "seed_name": multiworld.seed_name, "datapackage": data_package, } - AutoWorld.call_all(world, "modify_multidata", multidata) + AutoWorld.call_all(multiworld, "modify_multidata", multidata) multidata = zlib.compress(pickle.dumps(multidata), 9) @@ -423,7 +423,7 @@ def precollect_hint(location): output_file_futures.append(pool.submit(write_multidata)) if not check_accessibility_task.result(): - if not world.can_beat_game(): + if not multiworld.can_beat_game(): raise Exception("Game appears as unbeatable. Aborting.") else: logger.warning("Location Accessibility requirements not fulfilled.") @@ -436,12 +436,12 @@ def precollect_hint(location): if args.spoiler > 1: logger.info('Calculating playthrough.') - world.spoiler.create_playthrough(create_paths=args.spoiler > 2) + multiworld.spoiler.create_playthrough(create_paths=args.spoiler > 2) if args.spoiler: - world.spoiler.to_file(os.path.join(temp_dir, '%s_Spoiler.txt' % outfilebase)) + multiworld.spoiler.to_file(os.path.join(temp_dir, '%s_Spoiler.txt' % outfilebase)) - zipfilename = output_path(f"AP_{world.seed_name}.zip") + zipfilename = output_path(f"AP_{multiworld.seed_name}.zip") logger.info(f"Creating final archive at {zipfilename}") with zipfile.ZipFile(zipfilename, mode="w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as zf: @@ -449,4 +449,4 @@ def precollect_hint(location): zf.write(file.path, arcname=file.name) logger.info('Done. Enjoy. Total Time: %s', time.perf_counter() - start) - return world + return multiworld diff --git a/OoTAdjuster.py b/OoTAdjuster.py index 38ebe62e2ae..9519b191e70 100644 --- a/OoTAdjuster.py +++ b/OoTAdjuster.py @@ -195,10 +195,10 @@ def set_icon(window): window.tk.call('wm', 'iconphoto', window._w, logo) def adjust(args): - # Create a fake world and OOTWorld to use as a base - world = MultiWorld(1) - world.per_slot_randoms = {1: random} - ootworld = OOTWorld(world, 1) + # Create a fake multiworld and OOTWorld to use as a base + multiworld = MultiWorld(1) + multiworld.per_slot_randoms = {1: random} + ootworld = OOTWorld(multiworld, 1) # Set options in the fake OOTWorld for name, option in chain(cosmetic_options.items(), sfx_options.items()): result = getattr(args, name, None) diff --git a/Utils.py b/Utils.py index f6e4a9ab605..8b91226bed9 100644 --- a/Utils.py +++ b/Utils.py @@ -871,8 +871,8 @@ def visualize_regions(root_region: Region, file_name: str, *, Example usage in Main code: from Utils import visualize_regions - for player in world.player_ids: - visualize_regions(world.get_region("Menu", player), f"{world.get_out_file_name_base(player)}.puml") + for player in multiworld.player_ids: + visualize_regions(multiworld.get_region("Menu", player), f"{multiworld.get_out_file_name_base(player)}.puml") """ assert root_region.multiworld, "The multiworld attribute of root_region has to be filled" from BaseClasses import Entrance, Item, Location, LocationProgressType, MultiWorld, Region diff --git a/test/general/test_fill.py b/test/general/test_fill.py index e454b3e61d7..489417771d2 100644 --- a/test/general/test_fill.py +++ b/test/general/test_fill.py @@ -11,30 +11,30 @@ from worlds.generic.Rules import CollectionRule, add_item_rule, locality_rules, set_rule -def generate_multi_world(players: int = 1) -> MultiWorld: - multi_world = MultiWorld(players) - multi_world.player_name = {} - multi_world.state = CollectionState(multi_world) +def generate_multiworld(players: int = 1) -> MultiWorld: + multiworld = MultiWorld(players) + multiworld.player_name = {} + multiworld.state = CollectionState(multiworld) for i in range(players): player_id = i+1 - world = World(multi_world, player_id) - multi_world.game[player_id] = f"Game {player_id}" - multi_world.worlds[player_id] = world - multi_world.player_name[player_id] = "Test Player " + str(player_id) - region = Region("Menu", player_id, multi_world, "Menu Region Hint") - multi_world.regions.append(region) + world = World(multiworld, player_id) + multiworld.game[player_id] = f"Game {player_id}" + multiworld.worlds[player_id] = world + multiworld.player_name[player_id] = "Test Player " + str(player_id) + region = Region("Menu", player_id, multiworld, "Menu Region Hint") + multiworld.regions.append(region) for option_key, option in Options.PerGameCommonOptions.type_hints.items(): - if hasattr(multi_world, option_key): - getattr(multi_world, option_key).setdefault(player_id, option.from_any(getattr(option, "default"))) + if hasattr(multiworld, option_key): + getattr(multiworld, option_key).setdefault(player_id, option.from_any(getattr(option, "default"))) else: - setattr(multi_world, option_key, {player_id: option.from_any(getattr(option, "default"))}) + setattr(multiworld, option_key, {player_id: option.from_any(getattr(option, "default"))}) # TODO - remove this loop once all worlds use options dataclasses - world.options = world.options_dataclass(**{option_key: getattr(multi_world, option_key)[player_id] + world.options = world.options_dataclass(**{option_key: getattr(multiworld, option_key)[player_id] for option_key in world.options_dataclass.type_hints}) - multi_world.set_seed(0) + multiworld.set_seed(0) - return multi_world + return multiworld class PlayerDefinition(object): @@ -46,8 +46,8 @@ class PlayerDefinition(object): basic_items: List[Item] regions: List[Region] - def __init__(self, world: MultiWorld, id: int, menu: Region, locations: List[Location] = [], prog_items: List[Item] = [], basic_items: List[Item] = []): - self.multiworld = world + def __init__(self, multiworld: MultiWorld, id: int, menu: Region, locations: List[Location] = [], prog_items: List[Item] = [], basic_items: List[Item] = []): + self.multiworld = multiworld self.id = id self.menu = menu self.locations = locations @@ -72,7 +72,7 @@ def generate_region(self, parent: Region, size: int, access_rule: CollectionRule return region -def fill_region(world: MultiWorld, region: Region, items: List[Item]) -> List[Item]: +def fill_region(multiworld: MultiWorld, region: Region, items: List[Item]) -> List[Item]: items = items.copy() while len(items) > 0: location = region.locations.pop(0) @@ -80,7 +80,7 @@ def fill_region(world: MultiWorld, region: Region, items: List[Item]) -> List[It if location.item: return items item = items.pop(0) - world.push_item(location, item, False) + multiworld.push_item(location, item, False) location.event = item.advancement return items @@ -94,15 +94,15 @@ def region_contains(region: Region, item: Item) -> bool: return False -def generate_player_data(multi_world: MultiWorld, player_id: int, location_count: int = 0, prog_item_count: int = 0, basic_item_count: int = 0) -> PlayerDefinition: - menu = multi_world.get_region("Menu", player_id) +def generate_player_data(multiworld: MultiWorld, player_id: int, location_count: int = 0, prog_item_count: int = 0, basic_item_count: int = 0) -> PlayerDefinition: + menu = multiworld.get_region("Menu", player_id) locations = generate_locations(location_count, player_id, None, menu) prog_items = generate_items(prog_item_count, player_id, True) - multi_world.itempool += prog_items + multiworld.itempool += prog_items basic_items = generate_items(basic_item_count, player_id, False) - multi_world.itempool += basic_items + multiworld.itempool += basic_items - return PlayerDefinition(multi_world, player_id, menu, locations, prog_items, basic_items) + return PlayerDefinition(multiworld, player_id, menu, locations, prog_items, basic_items) def generate_locations(count: int, player_id: int, address: int = None, region: Region = None, tag: str = "") -> List[Location]: @@ -134,15 +134,15 @@ def names(objs: list) -> Iterable[str]: class TestFillRestrictive(unittest.TestCase): def test_basic_fill(self): """Tests `fill_restrictive` fills and removes the locations and items from their respective lists""" - multi_world = generate_multi_world() - player1 = generate_player_data(multi_world, 1, 2, 2) + multiworld = generate_multiworld() + player1 = generate_player_data(multiworld, 1, 2, 2) item0 = player1.prog_items[0] item1 = player1.prog_items[1] loc0 = player1.locations[0] loc1 = player1.locations[1] - fill_restrictive(multi_world, multi_world.state, + fill_restrictive(multiworld, multiworld.state, player1.locations, player1.prog_items) self.assertEqual(loc0.item, item1) @@ -152,16 +152,16 @@ def test_basic_fill(self): def test_ordered_fill(self): """Tests `fill_restrictive` fulfills set rules""" - multi_world = generate_multi_world() - player1 = generate_player_data(multi_world, 1, 2, 2) + multiworld = generate_multiworld() + player1 = generate_player_data(multiworld, 1, 2, 2) items = player1.prog_items locations = player1.locations - multi_world.completion_condition[player1.id] = lambda state: state.has( + multiworld.completion_condition[player1.id] = lambda state: state.has( items[0].name, player1.id) and state.has(items[1].name, player1.id) set_rule(locations[1], lambda state: state.has( items[0].name, player1.id)) - fill_restrictive(multi_world, multi_world.state, + fill_restrictive(multiworld, multiworld.state, player1.locations.copy(), player1.prog_items.copy()) self.assertEqual(locations[0].item, items[0]) @@ -169,8 +169,8 @@ def test_ordered_fill(self): def test_partial_fill(self): """Tests that `fill_restrictive` returns unfilled locations""" - multi_world = generate_multi_world() - player1 = generate_player_data(multi_world, 1, 3, 2) + multiworld = generate_multiworld() + player1 = generate_player_data(multiworld, 1, 3, 2) item0 = player1.prog_items[0] item1 = player1.prog_items[1] @@ -178,14 +178,14 @@ def test_partial_fill(self): loc1 = player1.locations[1] loc2 = player1.locations[2] - multi_world.completion_condition[player1.id] = lambda state: state.has( + multiworld.completion_condition[player1.id] = lambda state: state.has( item0.name, player1.id) and state.has(item1.name, player1.id) set_rule(loc1, lambda state: state.has( item0.name, player1.id)) # forces a swap set_rule(loc2, lambda state: state.has( item0.name, player1.id)) - fill_restrictive(multi_world, multi_world.state, + fill_restrictive(multiworld, multiworld.state, player1.locations, player1.prog_items) self.assertEqual(loc0.item, item0) @@ -195,19 +195,19 @@ def test_partial_fill(self): def test_minimal_fill(self): """Test that fill for minimal player can have unreachable items""" - multi_world = generate_multi_world() - player1 = generate_player_data(multi_world, 1, 2, 2) + multiworld = generate_multiworld() + player1 = generate_player_data(multiworld, 1, 2, 2) items = player1.prog_items locations = player1.locations - multi_world.worlds[player1.id].options.accessibility = Accessibility.from_any(Accessibility.option_minimal) - multi_world.completion_condition[player1.id] = lambda state: state.has( + multiworld.worlds[player1.id].options.accessibility = Accessibility.from_any(Accessibility.option_minimal) + multiworld.completion_condition[player1.id] = lambda state: state.has( items[1].name, player1.id) set_rule(locations[1], lambda state: state.has( items[0].name, player1.id)) - fill_restrictive(multi_world, multi_world.state, + fill_restrictive(multiworld, multiworld.state, player1.locations.copy(), player1.prog_items.copy()) self.assertEqual(locations[0].item, items[1]) @@ -220,15 +220,15 @@ def test_minimal_mixed_fill(self): the non-minimal player get all items. """ - multi_world = generate_multi_world(2) - player1 = generate_player_data(multi_world, 1, 3, 3) - player2 = generate_player_data(multi_world, 2, 3, 3) + multiworld = generate_multiworld(2) + player1 = generate_player_data(multiworld, 1, 3, 3) + player2 = generate_player_data(multiworld, 2, 3, 3) - multi_world.accessibility[player1.id].value = multi_world.accessibility[player1.id].option_minimal - multi_world.accessibility[player2.id].value = multi_world.accessibility[player2.id].option_locations + multiworld.accessibility[player1.id].value = multiworld.accessibility[player1.id].option_minimal + multiworld.accessibility[player2.id].value = multiworld.accessibility[player2.id].option_locations - multi_world.completion_condition[player1.id] = lambda state: True - multi_world.completion_condition[player2.id] = lambda state: state.has(player2.prog_items[2].name, player2.id) + multiworld.completion_condition[player1.id] = lambda state: True + multiworld.completion_condition[player2.id] = lambda state: state.has(player2.prog_items[2].name, player2.id) set_rule(player1.locations[1], lambda state: state.has(player1.prog_items[0].name, player1.id)) set_rule(player1.locations[2], lambda state: state.has(player1.prog_items[1].name, player1.id)) @@ -241,28 +241,28 @@ def test_minimal_mixed_fill(self): # fill remaining locations with remaining items location_pool = player1.locations[1:] + player2.locations item_pool = player1.prog_items[:-1] + player2.prog_items - fill_restrictive(multi_world, multi_world.state, location_pool, item_pool) - multi_world.state.sweep_for_events() # collect everything + fill_restrictive(multiworld, multiworld.state, location_pool, item_pool) + multiworld.state.sweep_for_events() # collect everything # all of player2's locations and items should be accessible (not all of player1's) for item in player2.prog_items: - self.assertTrue(multi_world.state.has(item.name, player2.id), + self.assertTrue(multiworld.state.has(item.name, player2.id), f'{item} is unreachable in {item.location}') def test_reversed_fill(self): """Test a different set of rules can be satisfied""" - multi_world = generate_multi_world() - player1 = generate_player_data(multi_world, 1, 2, 2) + multiworld = generate_multiworld() + player1 = generate_player_data(multiworld, 1, 2, 2) item0 = player1.prog_items[0] item1 = player1.prog_items[1] loc0 = player1.locations[0] loc1 = player1.locations[1] - multi_world.completion_condition[player1.id] = lambda state: state.has( + multiworld.completion_condition[player1.id] = lambda state: state.has( item0.name, player1.id) and state.has(item1.name, player1.id) set_rule(loc1, lambda state: state.has(item1.name, player1.id)) - fill_restrictive(multi_world, multi_world.state, + fill_restrictive(multiworld, multiworld.state, player1.locations, player1.prog_items) self.assertEqual(loc0.item, item1) @@ -270,13 +270,13 @@ def test_reversed_fill(self): def test_multi_step_fill(self): """Test that fill is able to satisfy multiple spheres""" - multi_world = generate_multi_world() - player1 = generate_player_data(multi_world, 1, 4, 4) + multiworld = generate_multiworld() + player1 = generate_player_data(multiworld, 1, 4, 4) items = player1.prog_items locations = player1.locations - multi_world.completion_condition[player1.id] = lambda state: state.has( + multiworld.completion_condition[player1.id] = lambda state: state.has( items[2].name, player1.id) and state.has(items[3].name, player1.id) set_rule(locations[1], lambda state: state.has( items[0].name, player1.id)) @@ -285,7 +285,7 @@ def test_multi_step_fill(self): set_rule(locations[3], lambda state: state.has( items[1].name, player1.id)) - fill_restrictive(multi_world, multi_world.state, + fill_restrictive(multiworld, multiworld.state, player1.locations.copy(), player1.prog_items.copy()) self.assertEqual(locations[0].item, items[1]) @@ -295,25 +295,25 @@ def test_multi_step_fill(self): def test_impossible_fill(self): """Test that fill raises an error when it can't place any items""" - multi_world = generate_multi_world() - player1 = generate_player_data(multi_world, 1, 2, 2) + multiworld = generate_multiworld() + player1 = generate_player_data(multiworld, 1, 2, 2) items = player1.prog_items locations = player1.locations - multi_world.completion_condition[player1.id] = lambda state: state.has( + multiworld.completion_condition[player1.id] = lambda state: state.has( items[0].name, player1.id) and state.has(items[1].name, player1.id) set_rule(locations[1], lambda state: state.has( items[1].name, player1.id)) set_rule(locations[0], lambda state: state.has( items[0].name, player1.id)) - self.assertRaises(FillError, fill_restrictive, multi_world, multi_world.state, + self.assertRaises(FillError, fill_restrictive, multiworld, multiworld.state, player1.locations.copy(), player1.prog_items.copy()) def test_circular_fill(self): """Test that fill raises an error when it can't place all items""" - multi_world = generate_multi_world() - player1 = generate_player_data(multi_world, 1, 3, 3) + multiworld = generate_multiworld() + player1 = generate_player_data(multiworld, 1, 3, 3) item0 = player1.prog_items[0] item1 = player1.prog_items[1] @@ -322,46 +322,46 @@ def test_circular_fill(self): loc1 = player1.locations[1] loc2 = player1.locations[2] - multi_world.completion_condition[player1.id] = lambda state: state.has( + multiworld.completion_condition[player1.id] = lambda state: state.has( item0.name, player1.id) and state.has(item1.name, player1.id) and state.has(item2.name, player1.id) set_rule(loc1, lambda state: state.has(item0.name, player1.id)) set_rule(loc2, lambda state: state.has(item1.name, player1.id)) set_rule(loc0, lambda state: state.has(item2.name, player1.id)) - self.assertRaises(FillError, fill_restrictive, multi_world, multi_world.state, + self.assertRaises(FillError, fill_restrictive, multiworld, multiworld.state, player1.locations.copy(), player1.prog_items.copy()) def test_competing_fill(self): """Test that fill raises an error when it can't place items in a way to satisfy the conditions""" - multi_world = generate_multi_world() - player1 = generate_player_data(multi_world, 1, 2, 2) + multiworld = generate_multiworld() + player1 = generate_player_data(multiworld, 1, 2, 2) item0 = player1.prog_items[0] item1 = player1.prog_items[1] loc1 = player1.locations[1] - multi_world.completion_condition[player1.id] = lambda state: state.has( + multiworld.completion_condition[player1.id] = lambda state: state.has( item0.name, player1.id) and state.has(item0.name, player1.id) and state.has(item1.name, player1.id) set_rule(loc1, lambda state: state.has(item0.name, player1.id) and state.has(item1.name, player1.id)) - self.assertRaises(FillError, fill_restrictive, multi_world, multi_world.state, + self.assertRaises(FillError, fill_restrictive, multiworld, multiworld.state, player1.locations.copy(), player1.prog_items.copy()) def test_multiplayer_fill(self): """Test that items can be placed across worlds""" - multi_world = generate_multi_world(2) - player1 = generate_player_data(multi_world, 1, 2, 2) - player2 = generate_player_data(multi_world, 2, 2, 2) + multiworld = generate_multiworld(2) + player1 = generate_player_data(multiworld, 1, 2, 2) + player2 = generate_player_data(multiworld, 2, 2, 2) - multi_world.completion_condition[player1.id] = lambda state: state.has( + multiworld.completion_condition[player1.id] = lambda state: state.has( player1.prog_items[0].name, player1.id) and state.has( player1.prog_items[1].name, player1.id) - multi_world.completion_condition[player2.id] = lambda state: state.has( + multiworld.completion_condition[player2.id] = lambda state: state.has( player2.prog_items[0].name, player2.id) and state.has( player2.prog_items[1].name, player2.id) - fill_restrictive(multi_world, multi_world.state, player1.locations + + fill_restrictive(multiworld, multiworld.state, player1.locations + player2.locations, player1.prog_items + player2.prog_items) self.assertEqual(player1.locations[0].item, player1.prog_items[1]) @@ -371,21 +371,21 @@ def test_multiplayer_fill(self): def test_multiplayer_rules_fill(self): """Test that fill across worlds satisfies the rules""" - multi_world = generate_multi_world(2) - player1 = generate_player_data(multi_world, 1, 2, 2) - player2 = generate_player_data(multi_world, 2, 2, 2) + multiworld = generate_multiworld(2) + player1 = generate_player_data(multiworld, 1, 2, 2) + player2 = generate_player_data(multiworld, 2, 2, 2) - multi_world.completion_condition[player1.id] = lambda state: state.has( + multiworld.completion_condition[player1.id] = lambda state: state.has( player1.prog_items[0].name, player1.id) and state.has( player1.prog_items[1].name, player1.id) - multi_world.completion_condition[player2.id] = lambda state: state.has( + multiworld.completion_condition[player2.id] = lambda state: state.has( player2.prog_items[0].name, player2.id) and state.has( player2.prog_items[1].name, player2.id) set_rule(player2.locations[1], lambda state: state.has( player2.prog_items[0].name, player2.id)) - fill_restrictive(multi_world, multi_world.state, player1.locations + + fill_restrictive(multiworld, multiworld.state, player1.locations + player2.locations, player1.prog_items + player2.prog_items) self.assertEqual(player1.locations[0].item, player2.prog_items[0]) @@ -395,10 +395,10 @@ def test_multiplayer_rules_fill(self): def test_restrictive_progress(self): """Test that various spheres with different requirements can be filled""" - multi_world = generate_multi_world() - player1 = generate_player_data(multi_world, 1, prog_item_count=25) + multiworld = generate_multiworld() + player1 = generate_player_data(multiworld, 1, prog_item_count=25) items = player1.prog_items.copy() - multi_world.completion_condition[player1.id] = lambda state: state.has_all( + multiworld.completion_condition[player1.id] = lambda state: state.has_all( names(player1.prog_items), player1.id) player1.generate_region(player1.menu, 5) @@ -411,16 +411,16 @@ def test_restrictive_progress(self): player1.generate_region(player1.menu, 5, lambda state: state.has_all( names(items[17:22]), player1.id)) - locations = multi_world.get_unfilled_locations() + locations = multiworld.get_unfilled_locations() - fill_restrictive(multi_world, multi_world.state, + fill_restrictive(multiworld, multiworld.state, locations, player1.prog_items) def test_swap_to_earlier_location_with_item_rule(self): """Test that item swap happens and works as intended""" # test for PR#1109 - multi_world = generate_multi_world(1) - player1 = generate_player_data(multi_world, 1, 4, 4) + multiworld = generate_multiworld(1) + player1 = generate_player_data(multiworld, 1, 4, 4) locations = player1.locations[:] # copy required items = player1.prog_items[:] # copy required # for the test to work, item and location order is relevant: Sphere 1 last, allowed_item not last @@ -437,15 +437,15 @@ def test_swap_to_earlier_location_with_item_rule(self): self.assertTrue(sphere1_loc.can_fill(None, allowed_item, False), "Test is flawed") self.assertFalse(sphere1_loc.can_fill(None, items[2], False), "Test is flawed") # fill has to place items[1] in locations[0] which will result in a swap because of placement order - fill_restrictive(multi_world, multi_world.state, player1.locations, player1.prog_items) + fill_restrictive(multiworld, multiworld.state, player1.locations, player1.prog_items) # assert swap happened self.assertTrue(sphere1_loc.item, "Did not swap required item into Sphere 1") self.assertEqual(sphere1_loc.item, allowed_item, "Wrong item in Sphere 1") def test_swap_to_earlier_location_with_item_rule2(self): """Test that swap works before all items are placed""" - multi_world = generate_multi_world(1) - player1 = generate_player_data(multi_world, 1, 5, 5) + multiworld = generate_multiworld(1) + player1 = generate_player_data(multiworld, 1, 5, 5) locations = player1.locations[:] # copy required items = player1.prog_items[:] # copy required # Two items provide access to sphere 2. @@ -477,7 +477,7 @@ def test_swap_to_earlier_location_with_item_rule2(self): # Now fill should place one_to_two1 in sphere1_loc1 or sphere1_loc2 via swap, # which it will attempt before two_to_three and three_to_four are placed, testing the behavior. - fill_restrictive(multi_world, multi_world.state, player1.locations, player1.prog_items) + fill_restrictive(multiworld, multiworld.state, player1.locations, player1.prog_items) # assert swap happened self.assertTrue(sphere1_loc1.item and sphere1_loc2.item, "Did not swap required item into Sphere 1") self.assertTrue(sphere1_loc1.item.name == one_to_two1 or @@ -486,29 +486,29 @@ def test_swap_to_earlier_location_with_item_rule2(self): def test_double_sweep(self): """Test that sweep doesn't duplicate Event items when sweeping""" # test for PR1114 - multi_world = generate_multi_world(1) - player1 = generate_player_data(multi_world, 1, 1, 1) + multiworld = generate_multiworld(1) + player1 = generate_player_data(multiworld, 1, 1, 1) location = player1.locations[0] location.address = None location.event = True item = player1.prog_items[0] item.code = None location.place_locked_item(item) - multi_world.state.sweep_for_events() - multi_world.state.sweep_for_events() - self.assertTrue(multi_world.state.prog_items[item.player][item.name], "Sweep did not collect - Test flawed") - self.assertEqual(multi_world.state.prog_items[item.player][item.name], 1, "Sweep collected multiple times") + multiworld.state.sweep_for_events() + multiworld.state.sweep_for_events() + self.assertTrue(multiworld.state.prog_items[item.player][item.name], "Sweep did not collect - Test flawed") + self.assertEqual(multiworld.state.prog_items[item.player][item.name], 1, "Sweep collected multiple times") def test_correct_item_instance_removed_from_pool(self): """Test that a placed item gets removed from the submitted pool""" - multi_world = generate_multi_world() - player1 = generate_player_data(multi_world, 1, 2, 2) + multiworld = generate_multiworld() + player1 = generate_player_data(multiworld, 1, 2, 2) player1.prog_items[0].name = "Different_item_instance_but_same_item_name" player1.prog_items[1].name = "Different_item_instance_but_same_item_name" loc0 = player1.locations[0] - fill_restrictive(multi_world, multi_world.state, + fill_restrictive(multiworld, multiworld.state, [loc0], player1.prog_items) self.assertEqual(1, len(player1.prog_items)) @@ -518,14 +518,14 @@ def test_correct_item_instance_removed_from_pool(self): class TestDistributeItemsRestrictive(unittest.TestCase): def test_basic_distribute(self): """Test that distribute_items_restrictive is deterministic""" - multi_world = generate_multi_world() + multiworld = generate_multiworld() player1 = generate_player_data( - multi_world, 1, 4, prog_item_count=2, basic_item_count=2) + multiworld, 1, 4, prog_item_count=2, basic_item_count=2) locations = player1.locations prog_items = player1.prog_items basic_items = player1.basic_items - distribute_items_restrictive(multi_world) + distribute_items_restrictive(multiworld) self.assertEqual(locations[0].item, basic_items[1]) self.assertFalse(locations[0].event) @@ -538,52 +538,52 @@ def test_basic_distribute(self): def test_excluded_distribute(self): """Test that distribute_items_restrictive doesn't put advancement items on excluded locations""" - multi_world = generate_multi_world() + multiworld = generate_multiworld() player1 = generate_player_data( - multi_world, 1, 4, prog_item_count=2, basic_item_count=2) + multiworld, 1, 4, prog_item_count=2, basic_item_count=2) locations = player1.locations locations[1].progress_type = LocationProgressType.EXCLUDED locations[2].progress_type = LocationProgressType.EXCLUDED - distribute_items_restrictive(multi_world) + distribute_items_restrictive(multiworld) self.assertFalse(locations[1].item.advancement) self.assertFalse(locations[2].item.advancement) def test_non_excluded_item_distribute(self): """Test that useful items aren't placed on excluded locations""" - multi_world = generate_multi_world() + multiworld = generate_multiworld() player1 = generate_player_data( - multi_world, 1, 4, prog_item_count=2, basic_item_count=2) + multiworld, 1, 4, prog_item_count=2, basic_item_count=2) locations = player1.locations basic_items = player1.basic_items locations[1].progress_type = LocationProgressType.EXCLUDED basic_items[1].classification = ItemClassification.useful - distribute_items_restrictive(multi_world) + distribute_items_restrictive(multiworld) self.assertEqual(locations[1].item, basic_items[0]) def test_too_many_excluded_distribute(self): """Test that fill fails if it can't place all progression items due to too many excluded locations""" - multi_world = generate_multi_world() + multiworld = generate_multiworld() player1 = generate_player_data( - multi_world, 1, 4, prog_item_count=2, basic_item_count=2) + multiworld, 1, 4, prog_item_count=2, basic_item_count=2) locations = player1.locations locations[0].progress_type = LocationProgressType.EXCLUDED locations[1].progress_type = LocationProgressType.EXCLUDED locations[2].progress_type = LocationProgressType.EXCLUDED - self.assertRaises(FillError, distribute_items_restrictive, multi_world) + self.assertRaises(FillError, distribute_items_restrictive, multiworld) def test_non_excluded_item_must_distribute(self): """Test that fill fails if it can't place useful items due to too many excluded locations""" - multi_world = generate_multi_world() + multiworld = generate_multiworld() player1 = generate_player_data( - multi_world, 1, 4, prog_item_count=2, basic_item_count=2) + multiworld, 1, 4, prog_item_count=2, basic_item_count=2) locations = player1.locations basic_items = player1.basic_items @@ -592,47 +592,47 @@ def test_non_excluded_item_must_distribute(self): basic_items[0].classification = ItemClassification.useful basic_items[1].classification = ItemClassification.useful - self.assertRaises(FillError, distribute_items_restrictive, multi_world) + self.assertRaises(FillError, distribute_items_restrictive, multiworld) def test_priority_distribute(self): """Test that priority locations receive advancement items""" - multi_world = generate_multi_world() + multiworld = generate_multiworld() player1 = generate_player_data( - multi_world, 1, 4, prog_item_count=2, basic_item_count=2) + multiworld, 1, 4, prog_item_count=2, basic_item_count=2) locations = player1.locations locations[0].progress_type = LocationProgressType.PRIORITY locations[3].progress_type = LocationProgressType.PRIORITY - distribute_items_restrictive(multi_world) + distribute_items_restrictive(multiworld) self.assertTrue(locations[0].item.advancement) self.assertTrue(locations[3].item.advancement) def test_excess_priority_distribute(self): """Test that if there's more priority locations than advancement items, they can still fill""" - multi_world = generate_multi_world() + multiworld = generate_multiworld() player1 = generate_player_data( - multi_world, 1, 4, prog_item_count=2, basic_item_count=2) + multiworld, 1, 4, prog_item_count=2, basic_item_count=2) locations = player1.locations locations[0].progress_type = LocationProgressType.PRIORITY locations[1].progress_type = LocationProgressType.PRIORITY locations[2].progress_type = LocationProgressType.PRIORITY - distribute_items_restrictive(multi_world) + distribute_items_restrictive(multiworld) self.assertFalse(locations[3].item.advancement) def test_multiple_world_priority_distribute(self): """Test that priority fill can be satisfied for multiple worlds""" - multi_world = generate_multi_world(3) + multiworld = generate_multiworld(3) player1 = generate_player_data( - multi_world, 1, 4, prog_item_count=2, basic_item_count=2) + multiworld, 1, 4, prog_item_count=2, basic_item_count=2) player2 = generate_player_data( - multi_world, 2, 4, prog_item_count=1, basic_item_count=3) + multiworld, 2, 4, prog_item_count=1, basic_item_count=3) player3 = generate_player_data( - multi_world, 3, 6, prog_item_count=4, basic_item_count=2) + multiworld, 3, 6, prog_item_count=4, basic_item_count=2) player1.locations[2].progress_type = LocationProgressType.PRIORITY player1.locations[3].progress_type = LocationProgressType.PRIORITY @@ -644,7 +644,7 @@ def test_multiple_world_priority_distribute(self): player3.locations[2].progress_type = LocationProgressType.PRIORITY player3.locations[3].progress_type = LocationProgressType.PRIORITY - distribute_items_restrictive(multi_world) + distribute_items_restrictive(multiworld) self.assertTrue(player1.locations[2].item.advancement) self.assertTrue(player1.locations[3].item.advancement) @@ -656,9 +656,9 @@ def test_multiple_world_priority_distribute(self): def test_can_remove_locations_in_fill_hook(self): """Test that distribute_items_restrictive calls the fill hook and allows for item and location removal""" - multi_world = generate_multi_world() + multiworld = generate_multiworld() player1 = generate_player_data( - multi_world, 1, 4, prog_item_count=2, basic_item_count=2) + multiworld, 1, 4, prog_item_count=2, basic_item_count=2) removed_item: list[Item] = [] removed_location: list[Location] = [] @@ -667,21 +667,21 @@ def fill_hook(progitempool, usefulitempool, filleritempool, fill_locations): removed_item.append(filleritempool.pop(0)) removed_location.append(fill_locations.pop(0)) - multi_world.worlds[player1.id].fill_hook = fill_hook + multiworld.worlds[player1.id].fill_hook = fill_hook - distribute_items_restrictive(multi_world) + distribute_items_restrictive(multiworld) self.assertIsNone(removed_item[0].location) self.assertIsNone(removed_location[0].item) def test_seed_robust_to_item_order(self): """Test deterministic fill""" - mw1 = generate_multi_world() + mw1 = generate_multiworld() gen1 = generate_player_data( mw1, 1, 4, prog_item_count=2, basic_item_count=2) distribute_items_restrictive(mw1) - mw2 = generate_multi_world() + mw2 = generate_multiworld() gen2 = generate_player_data( mw2, 1, 4, prog_item_count=2, basic_item_count=2) mw2.itempool.append(mw2.itempool.pop(0)) @@ -694,12 +694,12 @@ def test_seed_robust_to_item_order(self): def test_seed_robust_to_location_order(self): """Test deterministic fill even if locations in a region are reordered""" - mw1 = generate_multi_world() + mw1 = generate_multiworld() gen1 = generate_player_data( mw1, 1, 4, prog_item_count=2, basic_item_count=2) distribute_items_restrictive(mw1) - mw2 = generate_multi_world() + mw2 = generate_multiworld() gen2 = generate_player_data( mw2, 1, 4, prog_item_count=2, basic_item_count=2) reg = mw2.get_region("Menu", gen2.id) @@ -713,45 +713,45 @@ def test_seed_robust_to_location_order(self): def test_can_reserve_advancement_items_for_general_fill(self): """Test that priority locations fill still satisfies item rules""" - multi_world = generate_multi_world() + multiworld = generate_multiworld() player1 = generate_player_data( - multi_world, 1, location_count=5, prog_item_count=5) + multiworld, 1, location_count=5, prog_item_count=5) items = player1.prog_items - multi_world.completion_condition[player1.id] = lambda state: state.has_all( + multiworld.completion_condition[player1.id] = lambda state: state.has_all( names(items), player1.id) location = player1.locations[0] location.progress_type = LocationProgressType.PRIORITY location.item_rule = lambda item: item not in items[:4] - distribute_items_restrictive(multi_world) + distribute_items_restrictive(multiworld) self.assertEqual(location.item, items[4]) def test_non_excluded_local_items(self): """Test that local items get placed locally in a multiworld""" - multi_world = generate_multi_world(2) + multiworld = generate_multiworld(2) player1 = generate_player_data( - multi_world, 1, location_count=5, basic_item_count=5) + multiworld, 1, location_count=5, basic_item_count=5) player2 = generate_player_data( - multi_world, 2, location_count=5, basic_item_count=5) + multiworld, 2, location_count=5, basic_item_count=5) - for item in multi_world.get_items(): + for item in multiworld.get_items(): item.classification = ItemClassification.useful - multi_world.local_items[player1.id].value = set(names(player1.basic_items)) - multi_world.local_items[player2.id].value = set(names(player2.basic_items)) - locality_rules(multi_world) + multiworld.local_items[player1.id].value = set(names(player1.basic_items)) + multiworld.local_items[player2.id].value = set(names(player2.basic_items)) + locality_rules(multiworld) - distribute_items_restrictive(multi_world) + distribute_items_restrictive(multiworld) - for item in multi_world.get_items(): + for item in multiworld.get_items(): self.assertEqual(item.player, item.location.player) self.assertFalse(item.location.event, False) def test_early_items(self) -> None: """Test that the early items API successfully places items early""" - mw = generate_multi_world(2) + mw = generate_multiworld(2) player1 = generate_player_data(mw, 1, location_count=5, basic_item_count=5) player2 = generate_player_data(mw, 2, location_count=5, basic_item_count=5) mw.early_items[1][player1.basic_items[0].name] = 1 @@ -810,19 +810,19 @@ def assertRegionContains(self, region: Region, item: Item) -> bool: "\n Contains" + str(list(map(lambda location: location.item, region.locations)))) def setUp(self) -> None: - multi_world = generate_multi_world(2) - self.multi_world = multi_world + multiworld = generate_multiworld(2) + self.multiworld = multiworld player1 = generate_player_data( - multi_world, 1, prog_item_count=2, basic_item_count=40) + multiworld, 1, prog_item_count=2, basic_item_count=40) self.player1 = player1 player2 = generate_player_data( - multi_world, 2, prog_item_count=2, basic_item_count=40) + multiworld, 2, prog_item_count=2, basic_item_count=40) self.player2 = player2 - multi_world.completion_condition[player1.id] = lambda state: state.has( + multiworld.completion_condition[player1.id] = lambda state: state.has( player1.prog_items[0].name, player1.id) and state.has( player1.prog_items[1].name, player1.id) - multi_world.completion_condition[player2.id] = lambda state: state.has( + multiworld.completion_condition[player2.id] = lambda state: state.has( player2.prog_items[0].name, player2.id) and state.has( player2.prog_items[1].name, player2.id) @@ -830,42 +830,42 @@ def setUp(self) -> None: # Sphere 1 region = player1.generate_region(player1.menu, 20) - items = fill_region(multi_world, region, [ + items = fill_region(multiworld, region, [ player1.prog_items[0]] + items) # Sphere 2 region = player1.generate_region( player1.regions[1], 20, lambda state: state.has(player1.prog_items[0].name, player1.id)) items = fill_region( - multi_world, region, [player1.prog_items[1], player2.prog_items[0]] + items) + multiworld, region, [player1.prog_items[1], player2.prog_items[0]] + items) # Sphere 3 region = player2.generate_region( player2.menu, 20, lambda state: state.has(player2.prog_items[0].name, player2.id)) - fill_region(multi_world, region, [player2.prog_items[1]] + items) + fill_region(multiworld, region, [player2.prog_items[1]] + items) def test_balances_progression(self) -> None: """Tests that progression balancing moves progression items earlier""" - self.multi_world.progression_balancing[self.player1.id].value = 50 - self.multi_world.progression_balancing[self.player2.id].value = 50 + self.multiworld.progression_balancing[self.player1.id].value = 50 + self.multiworld.progression_balancing[self.player2.id].value = 50 self.assertRegionContains( self.player1.regions[2], self.player2.prog_items[0]) - balance_multiworld_progression(self.multi_world) + balance_multiworld_progression(self.multiworld) self.assertRegionContains( self.player1.regions[1], self.player2.prog_items[0]) def test_balances_progression_light(self) -> None: """Test that progression balancing still moves items earlier on minimum value""" - self.multi_world.progression_balancing[self.player1.id].value = 1 - self.multi_world.progression_balancing[self.player2.id].value = 1 + self.multiworld.progression_balancing[self.player1.id].value = 1 + self.multiworld.progression_balancing[self.player2.id].value = 1 self.assertRegionContains( self.player1.regions[2], self.player2.prog_items[0]) - balance_multiworld_progression(self.multi_world) + balance_multiworld_progression(self.multiworld) # TODO: arrange for a result that's different from the default self.assertRegionContains( @@ -873,13 +873,13 @@ def test_balances_progression_light(self) -> None: def test_balances_progression_heavy(self) -> None: """Test that progression balancing moves items earlier on maximum value""" - self.multi_world.progression_balancing[self.player1.id].value = 99 - self.multi_world.progression_balancing[self.player2.id].value = 99 + self.multiworld.progression_balancing[self.player1.id].value = 99 + self.multiworld.progression_balancing[self.player2.id].value = 99 self.assertRegionContains( self.player1.regions[2], self.player2.prog_items[0]) - balance_multiworld_progression(self.multi_world) + balance_multiworld_progression(self.multiworld) # TODO: arrange for a result that's different from the default self.assertRegionContains( @@ -887,25 +887,25 @@ def test_balances_progression_heavy(self) -> None: def test_skips_balancing_progression(self) -> None: """Test that progression balancing is skipped when players have it disabled""" - self.multi_world.progression_balancing[self.player1.id].value = 0 - self.multi_world.progression_balancing[self.player2.id].value = 0 + self.multiworld.progression_balancing[self.player1.id].value = 0 + self.multiworld.progression_balancing[self.player2.id].value = 0 self.assertRegionContains( self.player1.regions[2], self.player2.prog_items[0]) - balance_multiworld_progression(self.multi_world) + balance_multiworld_progression(self.multiworld) self.assertRegionContains( self.player1.regions[2], self.player2.prog_items[0]) def test_ignores_priority_locations(self) -> None: """Test that progression items on priority locations don't get moved by balancing""" - self.multi_world.progression_balancing[self.player1.id].value = 50 - self.multi_world.progression_balancing[self.player2.id].value = 50 + self.multiworld.progression_balancing[self.player1.id].value = 50 + self.multiworld.progression_balancing[self.player2.id].value = 50 self.player2.prog_items[0].location.progress_type = LocationProgressType.PRIORITY - balance_multiworld_progression(self.multi_world) + balance_multiworld_progression(self.multiworld) self.assertRegionContains( self.player1.regions[2], self.player2.prog_items[0]) diff --git a/test/general/test_reachability.py b/test/general/test_reachability.py index cfd83c94046..e57c398b7be 100644 --- a/test/general/test_reachability.py +++ b/test/general/test_reachability.py @@ -36,15 +36,15 @@ def test_default_all_state_can_reach_everything(self): for game_name, world_type in AutoWorldRegister.world_types.items(): unreachable_regions = self.default_settings_unreachable_regions.get(game_name, set()) with self.subTest("Game", game=game_name): - world = setup_solo_multiworld(world_type) - excluded = world.worlds[1].options.exclude_locations.value - state = world.get_all_state(False) - for location in world.get_locations(): + multiworld = setup_solo_multiworld(world_type) + excluded = multiworld.worlds[1].options.exclude_locations.value + state = multiworld.get_all_state(False) + for location in multiworld.get_locations(): if location.name not in excluded: with self.subTest("Location should be reached", location=location): self.assertTrue(location.can_reach(state), f"{location.name} unreachable") - for region in world.get_regions(): + for region in multiworld.get_regions(): if region.name in unreachable_regions: with self.subTest("Region should be unreachable", region=region): self.assertFalse(region.can_reach(state)) @@ -53,15 +53,15 @@ def test_default_all_state_can_reach_everything(self): self.assertTrue(region.can_reach(state)) with self.subTest("Completion Condition"): - self.assertTrue(world.can_beat_game(state)) + self.assertTrue(multiworld.can_beat_game(state)) def test_default_empty_state_can_reach_something(self): """Ensure empty state can reach at least one location with the defined options""" for game_name, world_type in AutoWorldRegister.world_types.items(): with self.subTest("Game", game=game_name): - world = setup_solo_multiworld(world_type) - state = CollectionState(world) - all_locations = world.get_locations() + multiworld = setup_solo_multiworld(world_type) + state = CollectionState(multiworld) + all_locations = multiworld.get_locations() if all_locations: locations = set() for location in all_locations: From 4032cfb9eac0889e4ccfa6e24eb0ef970af7044a Mon Sep 17 00:00:00 2001 From: Fabian Dill Date: Tue, 6 Feb 2024 00:11:02 +0100 Subject: [PATCH 23/38] WebHost: provide None password to URI so it doesn't get stripped (#2777) --- WebHostLib/templates/macros.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WebHostLib/templates/macros.html b/WebHostLib/templates/macros.html index 0722ee31746..9cb48009a42 100644 --- a/WebHostLib/templates/macros.html +++ b/WebHostLib/templates/macros.html @@ -22,7 +22,7 @@ {% for patch in room.seed.slots|list|sort(attribute="player_id") %} {{ patch.player_id }} - {{ patch.player_name }} + {{ patch.player_name }} {{ patch.game }} {% if patch.data %} From 59ef0108428bdfbb7000fb9ae0c58158abb441e2 Mon Sep 17 00:00:00 2001 From: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> Date: Sat, 10 Feb 2024 16:07:11 -0500 Subject: [PATCH 24/38] Fill: Changing deprecated option getter (#2735) --- Fill.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Fill.py b/Fill.py index 97ce4cbdb57..ae44710469e 100644 --- a/Fill.py +++ b/Fill.py @@ -173,7 +173,7 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati # validate all placements and remove invalid ones state = sweep_from_pool(base_state, []) for placement in placements: - if multiworld.accessibility[placement.item.player] != "minimal" and not placement.can_reach(state): + if multiworld.worlds[placement.item.player].options.accessibility != "minimal" and not placement.can_reach(state): placement.item.location = None unplaced_items.append(placement.item) placement.item = None From 4a703c5aba295e27d18eac4ad69e086b217b33fe Mon Sep 17 00:00:00 2001 From: Justus Lind Date: Sun, 11 Feb 2024 09:49:58 +1000 Subject: [PATCH 25/38] Muse Dash: Add support for Muse Dash 4.0.0 Songs (#2810) --- worlds/musedash/MuseDashCollection.py | 3 ++- worlds/musedash/MuseDashData.txt | 24 ++++++++++++++++++++++-- worlds/musedash/Options.py | 2 +- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/worlds/musedash/MuseDashCollection.py b/worlds/musedash/MuseDashCollection.py index 6cd27c696c9..cc4cc71ce33 100644 --- a/worlds/musedash/MuseDashCollection.py +++ b/worlds/musedash/MuseDashCollection.py @@ -26,7 +26,8 @@ class MuseDashCollections: # MUSE_PLUS_DLC, # To be included when OptionSets are rendered as part of basic settings. # "maimai DX Limited-time Suite", # Part of Muse Plus. Goes away 31st Jan 2026. "Miku in Museland", # Paid DLC not included in Muse Plus - "MSR Anthology", # Part of Muse Plus. Goes away 20th Jan 2024. + "Rin Len's Mirrorland", # Paid DLC not included in Muse Plus + "MSR Anthology", # Now no longer available. ] DIFF_OVERRIDES: List[str] = [ diff --git a/worlds/musedash/MuseDashData.txt b/worlds/musedash/MuseDashData.txt index fe3574f31b6..ce5929bfd00 100644 --- a/worlds/musedash/MuseDashData.txt +++ b/worlds/musedash/MuseDashData.txt @@ -484,7 +484,7 @@ Hand in Hand|66-1|Miku in Museland|False|1|3|6| Cynical Night Plan|66-2|Miku in Museland|False|4|6|8| God-ish|66-3|Miku in Museland|False|4|7|10| Darling Dance|66-4|Miku in Museland|False|4|7|9| -Hatsune Creation Myth|66-5|Miku in Museland|False|6|8|10| +Hatsune Creation Myth|66-5|Miku in Museland|False|6|8|10|11 The Vampire|66-6|Miku in Museland|False|4|6|9| Future Eve|66-7|Miku in Museland|False|4|8|11| Unknown Mother Goose|66-8|Miku in Museland|False|4|8|10| @@ -509,4 +509,24 @@ INTERNET SURVIVOR|69-1|Touhou Mugakudan -3-|False|5|8|10| Shuki*RaiRai|69-2|Touhou Mugakudan -3-|False|5|7|9| HELLOHELL|69-3|Touhou Mugakudan -3-|False|4|7|10| Calamity Fortune|69-4|Touhou Mugakudan -3-|True|6|8|10|11 -Tsurupettan|69-5|Touhou Mugakudan -3-|True|2|5|8| \ No newline at end of file +Tsurupettan|69-5|Touhou Mugakudan -3-|True|2|5|8| +Twilight Poems|43-44|MD Plus Project|True|3|6|8| +All My Friends feat. RANASOL|43-45|MD Plus Project|True|4|7|9| +Heartache|43-46|MD Plus Project|True|5|7|10| +Blue Lemonade|43-47|MD Plus Project|True|3|6|8| +Haunted Dance|43-48|MD Plus Project|False|6|9|11| +Hey Vincent.|43-49|MD Plus Project|True|6|8|10| +Meteor feat. TEA|43-50|MD Plus Project|True|3|6|9| +Narcissism Angel|43-51|MD Plus Project|True|1|3|6| +AlterLuna|43-52|MD Plus Project|True|6|8|11| +Niki Tousen|43-53|MD Plus Project|True|6|8|10|11 +Rettou Joutou|70-0|Rin Len's Mirrorland|False|4|7|9| +Telecaster B-Boy|70-1|Rin Len's Mirrorland|False|5|7|10| +Iya Iya Iya|70-2|Rin Len's Mirrorland|False|2|4|7| +Nee Nee Nee|70-3|Rin Len's Mirrorland|False|4|6|8| +Chaotic Love Revolution|70-4|Rin Len's Mirrorland|False|4|6|8| +Dance of the Corpses|70-5|Rin Len's Mirrorland|False|2|5|8| +Bitter Choco Decoration|70-6|Rin Len's Mirrorland|False|3|6|9| +Dance Robot Dance|70-7|Rin Len's Mirrorland|False|4|7|10| +Sweet Devil|70-8|Rin Len's Mirrorland|False|5|7|9| +Someday'z Coming|70-9|Rin Len's Mirrorland|False|5|7|9| \ No newline at end of file diff --git a/worlds/musedash/Options.py b/worlds/musedash/Options.py index d5ce313f8f0..26ad5ff5d96 100644 --- a/worlds/musedash/Options.py +++ b/worlds/musedash/Options.py @@ -36,7 +36,7 @@ class AdditionalSongs(Range): - The final song count may be lower due to other settings. """ range_start = 15 - range_end = 508 # Note will probably not reach this high if any other settings are done. + range_end = 528 # Note will probably not reach this high if any other settings are done. default = 40 display_name = "Additional Song Count" From 03c3ef4e722ec1545f301603b7dcda6acf224847 Mon Sep 17 00:00:00 2001 From: JaredWeakStrike <96694163+JaredWeakStrike@users.noreply.github.com> Date: Sat, 10 Feb 2024 18:50:38 -0500 Subject: [PATCH 26/38] KH2: Fix Final Form logic softlock (#2803) --- worlds/kh2/Logic.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/worlds/kh2/Logic.py b/worlds/kh2/Logic.py index 1f13aa5f029..1e6c403106d 100644 --- a/worlds/kh2/Logic.py +++ b/worlds/kh2/Logic.py @@ -606,11 +606,11 @@ ItemName.LimitForm: 1, } final_leveling_access = { - LocationName.MemorysSkyscaperMythrilCrystal, + LocationName.RoxasEventLocation, LocationName.GrimReaper2, LocationName.Xaldin, LocationName.StormRider, - LocationName.SunsetTerraceAbilityRing + LocationName.UndergroundConcourseMythrilGem } multi_form_region_access = { From 1a675821cf15b6ad6d67eb50dd24ac20d5aaa3b6 Mon Sep 17 00:00:00 2001 From: Alchav <59858495+Alchav@users.noreply.github.com> Date: Sat, 10 Feb 2024 18:59:15 -0500 Subject: [PATCH 27/38] =?UTF-8?q?Pok=C3=A9mon=20R/B:=20Halve=20Bank=20Exch?= =?UTF-8?q?ange=20Rate=20(#2619)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- worlds/pokemon_rb/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/worlds/pokemon_rb/client.py b/worlds/pokemon_rb/client.py index 7424cc8ddff..9e2689bccc3 100644 --- a/worlds/pokemon_rb/client.py +++ b/worlds/pokemon_rb/client.py @@ -10,7 +10,7 @@ logger = logging.getLogger("Client") -BANK_EXCHANGE_RATE = 100000000 +BANK_EXCHANGE_RATE = 50000000 DATA_LOCATIONS = { "ItemIndex": (0x1A6E, 0x02), From 77c326cb818668d7c74ee1c38d76f39572e8de17 Mon Sep 17 00:00:00 2001 From: black-sliver <59490463+black-sliver@users.noreply.github.com> Date: Sun, 11 Feb 2024 01:07:23 +0100 Subject: [PATCH 28/38] FFMQ: fix __version__ import in Output.py (#2791) Importing from Main is a recursive import during Generate, also it's not listed in Main.__all__ (and pycharm warns about this). --- worlds/ffmq/Output.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/worlds/ffmq/Output.py b/worlds/ffmq/Output.py index 98ecd28986d..9daee9f643a 100644 --- a/worlds/ffmq/Output.py +++ b/worlds/ffmq/Output.py @@ -3,7 +3,7 @@ import zipfile from copy import deepcopy from .Regions import object_id_table -from Main import __version__ +from Utils import __version__ from worlds.Files import APContainer import pkgutil From a6deffb9f2b09fa45edc8ffdb9a0e04c501d154f Mon Sep 17 00:00:00 2001 From: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com> Date: Sun, 11 Feb 2024 02:25:03 +0100 Subject: [PATCH 29/38] The Witness: Change all option name comparisons to strings instead of numeric values (#2503) * Refactor postgame code to be more readable * Change all references to options to strings * oops * Fix some outdated code related to yaml-disabled EPs * Small fixes to short/longbox stuff (thanks Medic) * comment * fix duplicate * Removed triplicate lmfao * Better comment * added another 'unfun' postgame consideration * comment * more option strings * oops * Remove an unnecessary comparison * another string missed * Another was missed * This would create a really bad merge error --- worlds/witness/__init__.py | 6 +- worlds/witness/hints.py | 6 +- worlds/witness/items.py | 2 +- worlds/witness/locations.py | 4 +- worlds/witness/player_logic.py | 166 +++++++++++++++++++++------------ worlds/witness/regions.py | 8 +- 6 files changed, 119 insertions(+), 73 deletions(-) diff --git a/worlds/witness/__init__.py b/worlds/witness/__init__.py index a645abc0812..d99aab5cffb 100644 --- a/worlds/witness/__init__.py +++ b/worlds/witness/__init__.py @@ -161,7 +161,7 @@ def create_regions(self): early_items = [item for item in self.items.get_early_items() if item in self.items.get_mandatory_items()] if early_items: random_early_item = self.random.choice(early_items) - if self.options.puzzle_randomization == 1: + if self.options.puzzle_randomization == "sigma_expert": # In Expert, only tag the item as early, rather than forcing it onto the gate. self.multiworld.local_early_items[self.player][random_early_item] = 1 else: @@ -184,7 +184,7 @@ def create_regions(self): # Adjust the needed size for sphere 1 based on how restrictive the settings are in terms of items needed_size = 3 - needed_size += self.options.puzzle_randomization == 1 + needed_size += self.options.puzzle_randomization == "sigma_expert" needed_size += self.options.shuffle_symbols needed_size += self.options.shuffle_doors > 0 @@ -284,7 +284,7 @@ def fill_slot_data(self) -> dict: audio_logs = get_audio_logs().copy() - if hint_amount != 0: + if hint_amount: generated_hints = make_hints(self, hint_amount, self.own_itempool) self.random.shuffle(audio_logs) diff --git a/worlds/witness/hints.py b/worlds/witness/hints.py index e2d1069bd1d..0354660b5ee 100644 --- a/worlds/witness/hints.py +++ b/worlds/witness/hints.py @@ -176,15 +176,15 @@ def get_always_hint_items(world: "WitnessWorld") -> List[str]: wincon = world.options.victory_condition if discards: - if difficulty == 1: + if difficulty == "sigma_expert": always.append("Arrows") else: always.append("Triangles") - if wincon == 0: + if wincon == "elevator": always += ["Mountain Bottom Floor Final Room Entry (Door)", "Mountain Bottom Floor Doors"] - if wincon == 1: + if wincon == "challenge": always += ["Challenge Entry (Panel)", "Caves Panels"] return always diff --git a/worlds/witness/items.py b/worlds/witness/items.py index a8c889de937..3a8b35793a3 100644 --- a/worlds/witness/items.py +++ b/worlds/witness/items.py @@ -228,7 +228,7 @@ def get_early_items(self) -> List[str]: output = {"Dots", "Black/White Squares", "Symmetry", "Shapers", "Stars"} if self._world.options.shuffle_discarded_panels: - if self._world.options.puzzle_randomization == 1: + if self._world.options.puzzle_randomization == "sigma_expert": output.add("Arrows") else: output.add("Triangles") diff --git a/worlds/witness/locations.py b/worlds/witness/locations.py index 026977701a6..1f73c2c031a 100644 --- a/worlds/witness/locations.py +++ b/worlds/witness/locations.py @@ -509,9 +509,9 @@ def __init__(self, world: "WitnessWorld", player_logic: WitnessPlayerLogic): if world.options.shuffle_vault_boxes: self.PANEL_TYPES_TO_SHUFFLE.add("Vault") - if world.options.shuffle_EPs == 1: + if world.options.shuffle_EPs == "individual": self.PANEL_TYPES_TO_SHUFFLE.add("EP") - elif world.options.shuffle_EPs == 2: + elif world.options.shuffle_EPs == "obelisk_sides": self.PANEL_TYPES_TO_SHUFFLE.add("Obelisk Side") for obelisk_loc in StaticWitnessLocations.OBELISK_SIDES: diff --git a/worlds/witness/player_logic.py b/worlds/witness/player_logic.py index 5d538e62b74..24dfa7d9c87 100644 --- a/worlds/witness/player_logic.py +++ b/worlds/witness/player_logic.py @@ -253,51 +253,101 @@ def make_single_adjustment(self, adj_type: str, line: str): line = self.REFERENCE_LOGIC.ENTITIES_BY_HEX[line]["checkName"] self.ADDED_CHECKS.add(line) + @staticmethod + def handle_postgame(world: "WitnessWorld"): + # In shuffle_postgame, panels that become accessible "after or at the same time as the goal" are disabled. + # This has a lot of complicated considerations, which I'll try my best to explain. + postgame_adjustments = [] + + # Make some quick references to some options + doors = world.options.shuffle_doors >= 2 # "Panels" mode has no overarching region accessibility implications. + early_caves = world.options.early_caves + victory = world.options.victory_condition + mnt_lasers = world.options.mountain_lasers + chal_lasers = world.options.challenge_lasers + + # Goal is "short box" but short box requires more lasers than long box + reverse_shortbox_goal = victory == "mountain_box_short" and mnt_lasers > chal_lasers + + # Goal is "short box", and long box requires at least as many lasers as short box (as god intended) + proper_shortbox_goal = victory == "mountain_box_short" and chal_lasers >= mnt_lasers + + # Goal is "long box", but short box requires at least as many lasers than long box. + reverse_longbox_goal = victory == "mountain_box_long" and mnt_lasers >= chal_lasers + + # If goal is shortbox or "reverse longbox", you will never enter the mountain from the top before winning. + mountain_enterable_from_top = not (victory == "mountain_box_short" or reverse_longbox_goal) + + # Caves & Challenge should never have anything if doors are vanilla - definitionally "post-game" + # This is technically imprecise, but it matches player expectations better. + if not (early_caves or doors): + postgame_adjustments.append(get_caves_exclusion_list()) + postgame_adjustments.append(get_beyond_challenge_exclusion_list()) + + # If Challenge is the goal, some panels on the way need to be left on, as well as Challenge Vault box itself + if not victory == "challenge": + postgame_adjustments.append(get_path_to_challenge_exclusion_list()) + postgame_adjustments.append(get_challenge_vault_box_exclusion_list()) + + # Challenge can only have something if the goal is not challenge or longbox itself. + # In case of shortbox, it'd have to be a "reverse shortbox" situation where shortbox requires *more* lasers. + # In that case, it'd also have to be a doors mode, but that's already covered by the previous block. + if not (victory == "elevator" or reverse_shortbox_goal): + postgame_adjustments.append(get_beyond_challenge_exclusion_list()) + if not victory == "challenge": + postgame_adjustments.append(get_challenge_vault_box_exclusion_list()) + + # Mountain can't be reached if the goal is shortbox (or "reverse long box") + if not mountain_enterable_from_top: + postgame_adjustments.append(get_mountain_upper_exclusion_list()) + + # Same goes for lower mountain, but that one *can* be reached in remote doors modes. + if not doors: + postgame_adjustments.append(get_mountain_lower_exclusion_list()) + + # The Mountain Bottom Floor Discard is a bit complicated, so we handle it separately. ("it" == the Discard) + # In Elevator Goal, it is definitionally in the post-game, unless remote doors is played. + # In Challenge Goal, it is before the Challenge, so it is not post-game. + # In Short Box Goal, you can win before turning it on, UNLESS Short Box requires MORE lasers than long box. + # In Long Box Goal, it is always in the post-game because solving long box is what turns it on. + if not ((victory == "elevator" and doors) or victory == "challenge" or (reverse_shortbox_goal and doors)): + # We now know Bottom Floor Discard is in the post-game. + # This has different consequences depending on whether remote doors is being played. + # If doors are vanilla, Bottom Floor Discard locks a door to an area, which has to be disabled as well. + if doors: + postgame_adjustments.append(get_bottom_floor_discard_exclusion_list()) + else: + postgame_adjustments.append(get_bottom_floor_discard_nondoors_exclusion_list()) + + # In Challenge goal + early_caves + vanilla doors, you could find something important on Bottom Floor Discard, + # including the Caves Shortcuts themselves if playing "early_caves: start_inventory". + # This is another thing that was deemed "unfun" more than fitting the actual definition of post-game. + if victory == "challenge" and early_caves and not doors: + postgame_adjustments.append(get_bottom_floor_discard_nondoors_exclusion_list()) + + # If we have a proper short box goal, long box will never be activated first. + if proper_shortbox_goal: + postgame_adjustments.append(["Disabled Locations:", "0xFFF00 (Mountain Box Long)"]) + + return postgame_adjustments + def make_options_adjustments(self, world: "WitnessWorld"): """Makes logic adjustments based on options""" adjustment_linesets_in_order = [] - # Postgame + # Make condensed references to some options - doors = world.options.shuffle_doors >= 2 + doors = world.options.shuffle_doors >= 2 # "Panels" mode has no overarching region accessibility implications. lasers = world.options.shuffle_lasers - early_caves = world.options.early_caves > 0 victory = world.options.victory_condition mnt_lasers = world.options.mountain_lasers chal_lasers = world.options.challenge_lasers - mountain_enterable_from_top = victory == 0 or victory == 1 or (victory == 3 and chal_lasers > mnt_lasers) - + # Exclude panels from the post-game if shuffle_postgame is false. if not world.options.shuffle_postgame: - if not (early_caves or doors): - adjustment_linesets_in_order.append(get_caves_exclusion_list()) - if not victory == 1: - adjustment_linesets_in_order.append(get_path_to_challenge_exclusion_list()) - adjustment_linesets_in_order.append(get_challenge_vault_box_exclusion_list()) - adjustment_linesets_in_order.append(get_beyond_challenge_exclusion_list()) - - if not ((doors or early_caves) and (victory == 0 or (victory == 2 and mnt_lasers > chal_lasers))): - adjustment_linesets_in_order.append(get_beyond_challenge_exclusion_list()) - if not victory == 1: - adjustment_linesets_in_order.append(get_challenge_vault_box_exclusion_list()) - - if not (doors or mountain_enterable_from_top): - adjustment_linesets_in_order.append(get_mountain_lower_exclusion_list()) - - if not mountain_enterable_from_top: - adjustment_linesets_in_order.append(get_mountain_upper_exclusion_list()) - - if not ((victory == 0 and doors) or victory == 1 or (victory == 2 and mnt_lasers > chal_lasers and doors)): - if doors: - adjustment_linesets_in_order.append(get_bottom_floor_discard_exclusion_list()) - else: - adjustment_linesets_in_order.append(get_bottom_floor_discard_nondoors_exclusion_list()) - - if victory == 2 and chal_lasers >= mnt_lasers: - adjustment_linesets_in_order.append(["Disabled Locations:", "0xFFF00 (Mountain Box Long)"]) + adjustment_linesets_in_order += self.handle_postgame(world) # Exclude Discards / Vaults - if not world.options.shuffle_discarded_panels: # In disable_non_randomized, the discards are needed for alternate activation triggers, UNLESS both # (remote) doors and lasers are shuffled. @@ -309,18 +359,18 @@ def make_options_adjustments(self, world: "WitnessWorld"): if not world.options.shuffle_vault_boxes: adjustment_linesets_in_order.append(get_vault_exclusion_list()) - if not victory == 1: + if not victory == "challenge": adjustment_linesets_in_order.append(get_challenge_vault_box_exclusion_list()) # Victory Condition - if victory == 0: + if victory == "elevator": self.VICTORY_LOCATION = "0x3D9A9" - elif victory == 1: + elif victory == "challenge": self.VICTORY_LOCATION = "0x0356B" - elif victory == 2: + elif victory == "mountain_box_short": self.VICTORY_LOCATION = "0x09F7F" - elif victory == 3: + elif victory == "mountain_box_long": self.VICTORY_LOCATION = "0xFFF00" # Long box can usually only be solved by opening Mountain Entry. However, if it requires 7 lasers or less @@ -338,36 +388,36 @@ def make_options_adjustments(self, world: "WitnessWorld"): if world.options.shuffle_symbols: adjustment_linesets_in_order.append(get_symbol_shuffle_list()) - if world.options.EP_difficulty == 0: + if world.options.EP_difficulty == "normal": adjustment_linesets_in_order.append(get_ep_easy()) - elif world.options.EP_difficulty == 1: + elif world.options.EP_difficulty == "tedious": adjustment_linesets_in_order.append(get_ep_no_eclipse()) - if world.options.door_groupings == 1: - if world.options.shuffle_doors == 1: + if world.options.door_groupings == "regional": + if world.options.shuffle_doors == "panels": adjustment_linesets_in_order.append(get_simple_panels()) - elif world.options.shuffle_doors == 2: + elif world.options.shuffle_doors == "doors": adjustment_linesets_in_order.append(get_simple_doors()) - elif world.options.shuffle_doors == 3: + elif world.options.shuffle_doors == "mixed": adjustment_linesets_in_order.append(get_simple_doors()) adjustment_linesets_in_order.append(get_simple_additional_panels()) else: - if world.options.shuffle_doors == 1: + if world.options.shuffle_doors == "panels": adjustment_linesets_in_order.append(get_complex_door_panels()) adjustment_linesets_in_order.append(get_complex_additional_panels()) - elif world.options.shuffle_doors == 2: + elif world.options.shuffle_doors == "doors": adjustment_linesets_in_order.append(get_complex_doors()) - elif world.options.shuffle_doors == 3: + elif world.options.shuffle_doors == "mixed": adjustment_linesets_in_order.append(get_complex_doors()) adjustment_linesets_in_order.append(get_complex_additional_panels()) if world.options.shuffle_boat: adjustment_linesets_in_order.append(get_boat()) - if world.options.early_caves == 2: + if world.options.early_caves == "starting_inventory": adjustment_linesets_in_order.append(get_early_caves_start_list()) - if world.options.early_caves == 1 and not doors: + if world.options.early_caves == "add_to_pool" and not doors: adjustment_linesets_in_order.append(get_early_caves_list()) if world.options.elevators_come_to_you: @@ -391,25 +441,21 @@ def make_options_adjustments(self, world: "WitnessWorld"): else: adjustment_linesets_in_order.append(["Disabled Locations:"] + get_ep_obelisks()[1:]) - if world.options.shuffle_EPs == 0: + if not world.options.shuffle_EPs: adjustment_linesets_in_order.append(["Irrelevant Locations:"] + get_ep_all_individual()[1:]) - yaml_disabled_eps = [] - for yaml_disabled_location in self.YAML_DISABLED_LOCATIONS: if yaml_disabled_location not in self.REFERENCE_LOGIC.ENTITIES_BY_NAME: continue loc_obj = self.REFERENCE_LOGIC.ENTITIES_BY_NAME[yaml_disabled_location] - if loc_obj["entityType"] == "EP" and world.options.shuffle_EPs != 0: - yaml_disabled_eps.append(loc_obj["entity_hex"]) + if loc_obj["entityType"] == "EP": + self.COMPLETELY_DISABLED_ENTITIES.add(loc_obj["entity_hex"]) - if loc_obj["entityType"] in {"EP", "General", "Vault", "Discard"}: + elif loc_obj["entityType"] in {"General", "Vault", "Discard"}: self.EXCLUDED_LOCATIONS.add(loc_obj["entity_hex"]) - adjustment_linesets_in_order.append(["Disabled Locations:"] + yaml_disabled_eps) - for adjustment_lineset in adjustment_linesets_in_order: current_adjustment_type = None @@ -519,13 +565,13 @@ def __init__(self, world: "WitnessWorld", disabled_locations: Set[str], start_in self.DOOR_ITEMS_BY_ID: Dict[str, List[str]] = {} self.STARTING_INVENTORY = set() - self.DIFFICULTY = world.options.puzzle_randomization.value + self.DIFFICULTY = world.options.puzzle_randomization - if self.DIFFICULTY == 0: + if self.DIFFICULTY == "sigma_normal": self.REFERENCE_LOGIC = StaticWitnessLogic.sigma_normal - elif self.DIFFICULTY == 1: + elif self.DIFFICULTY == "sigma_expert": self.REFERENCE_LOGIC = StaticWitnessLogic.sigma_expert - elif self.DIFFICULTY == 2: + elif self.DIFFICULTY == "none": self.REFERENCE_LOGIC = StaticWitnessLogic.vanilla self.CONNECTIONS_BY_REGION_NAME = copy.copy(self.REFERENCE_LOGIC.STATIC_CONNECTIONS_BY_REGION_NAME) diff --git a/worlds/witness/regions.py b/worlds/witness/regions.py index e0970248051..3a1a1781b77 100644 --- a/worlds/witness/regions.py +++ b/worlds/witness/regions.py @@ -134,13 +134,13 @@ def create_regions(self, world: "WitnessWorld", player_logic: WitnessPlayerLogic world.multiworld.regions += final_regions_list def __init__(self, locat: WitnessPlayerLocations, world: "WitnessWorld"): - difficulty = world.options.puzzle_randomization.value + difficulty = world.options.puzzle_randomization - if difficulty == 0: + if difficulty == "sigma_normal": self.reference_logic = StaticWitnessLogic.sigma_normal - elif difficulty == 1: + elif difficulty == "sigma_expert": self.reference_logic = StaticWitnessLogic.sigma_expert - elif difficulty == 2: + elif difficulty == "none": self.reference_logic = StaticWitnessLogic.vanilla self.locat = locat From 151e2c3ac2d6db1e3063f437884c7fa64d3e471c Mon Sep 17 00:00:00 2001 From: Scipio Wright Date: Sat, 10 Feb 2024 21:15:46 -0500 Subject: [PATCH 30/38] TUNIC: Add an ER static connection, modify an nmg rule (#2802) * Add laurels connection at monastery front * Removed an entrance rule to prevent people from being expected to softlock themselves --- worlds/tunic/er_rules.py | 7 +++++++ worlds/tunic/options.py | 3 ++- worlds/tunic/rules.py | 3 +-- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/worlds/tunic/er_rules.py b/worlds/tunic/er_rules.py index ab0cf02bd97..ee7cca45361 100644 --- a/worlds/tunic/er_rules.py +++ b/worlds/tunic/er_rules.py @@ -444,6 +444,13 @@ def set_er_region_rules(world: "TunicWorld", ability_unlocks: Dict[str, int], re regions["Quarry"].connect( connecting_region=regions["Quarry Monastery Entry"]) + regions["Quarry Monastery Entry"].connect( + connecting_region=regions["Quarry Back"], + rule=lambda state: state.has(laurels, player)) + regions["Quarry Back"].connect( + connecting_region=regions["Quarry Monastery Entry"], + rule=lambda state: state.has(laurels, player)) + regions["Monastery Rope"].connect( connecting_region=regions["Quarry Back"]) diff --git a/worlds/tunic/options.py b/worlds/tunic/options.py index 77fa2cdaf5b..1838941bc9c 100644 --- a/worlds/tunic/options.py +++ b/worlds/tunic/options.py @@ -37,8 +37,9 @@ class LogicRules(Choice): """Set which logic rules to use for your world. Restricted: Standard logic, no glitches. No Major Glitches: Ice grapples through doors, shooting the west bell, and boss quick kills are included in logic. + * Ice grappling through the Ziggurat door is not in logic since you will get stuck in there without Prayer Unrestricted: Logic in No Major Glitches, as well as ladder storage to get to certain places early. - *Special Shop is not in logic without the Hero's Laurels in Unrestricted due to soft lock potential. + *Special Shop is not in logic without the Hero's Laurels due to soft lock potential. *Using Ladder Storage to get to individual chests is not in logic to avoid tedium. *Getting knocked out of the air by enemies during Ladder Storage to reach places is not in logic, except for in Rooted Ziggurat Lower. This is so you're not punished for playing with enemy rando on.""" diff --git a/worlds/tunic/rules.py b/worlds/tunic/rules.py index 9906936a469..df81335655e 100644 --- a/worlds/tunic/rules.py +++ b/worlds/tunic/rules.py @@ -130,8 +130,7 @@ def set_region_rules(world: "TunicWorld", ability_unlocks: Dict[str, int]) -> No multiworld.get_entrance("Quarry -> Lower Quarry", player).access_rule = \ lambda state: has_mask(state, player, options) multiworld.get_entrance("Lower Quarry -> Rooted Ziggurat", player).access_rule = \ - lambda state: (state.has(grapple, player) and has_ability(state, player, prayer, options, ability_unlocks)) \ - or has_ice_grapple_logic(False, state, player, options, ability_unlocks) + lambda state: state.has(grapple, player) and has_ability(state, player, prayer, options, ability_unlocks) multiworld.get_entrance("Quarry -> Rooted Ziggurat", player).access_rule = \ lambda state: has_ice_grapple_logic(False, state, player, options, ability_unlocks) multiworld.get_entrance("Swamp -> Cathedral", player).access_rule = \ From 6f3bc3a7ad1f32380f000be7c13d5afda5b372d7 Mon Sep 17 00:00:00 2001 From: Alchav <59858495+Alchav@users.noreply.github.com> Date: Mon, 12 Feb 2024 20:45:39 -0500 Subject: [PATCH 31/38] Core: Minimal-Items Accessibility Fix (#1888) --- BaseClasses.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/BaseClasses.py b/BaseClasses.py index 39f822668c4..15470f82a09 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -572,9 +572,10 @@ def fulfills_accessibility(self, state: Optional[CollectionState] = None): def location_condition(location: Location): """Determine if this location has to be accessible, location is already filtered by location_relevant""" - if location.player in players["minimal"]: - return False - return True + if location.player in players["locations"] or (location.item and location.item.player not in + players["minimal"]): + return True + return False def location_relevant(location: Location): """Determine if this location is relevant to sweep.""" From 0c8f726393b11e173e71aa00d06c165ef86f340d Mon Sep 17 00:00:00 2001 From: Magnemania <89949176+Magnemania@users.noreply.github.com> Date: Tue, 13 Feb 2024 00:14:21 -0500 Subject: [PATCH 32/38] SM64: Move Randomizer Content Update (#2569) * Super Mario 64: Move Randomizer Update Co-authored-by: RBman <139954693+RBmans@users.noreply.github.com> Signed-off-by: Magnemania * Fixed logic for Vanish Cap Under the Moat Signed-off-by: Magnemania --- worlds/sm64ex/Items.py | 17 +- worlds/sm64ex/Options.py | 88 ++++++--- worlds/sm64ex/Regions.py | 196 +++++++++++--------- worlds/sm64ex/Rules.py | 379 ++++++++++++++++++++++++++++++-------- worlds/sm64ex/__init__.py | 82 ++++++--- 5 files changed, 546 insertions(+), 216 deletions(-) diff --git a/worlds/sm64ex/Items.py b/worlds/sm64ex/Items.py index 5b429a23cdc..546f1abd316 100644 --- a/worlds/sm64ex/Items.py +++ b/worlds/sm64ex/Items.py @@ -16,6 +16,21 @@ class SM64Item(Item): "1Up Mushroom": 3626184 } +action_item_table = { + "Double Jump": 3626185, + "Triple Jump": 3626186, + "Long Jump": 3626187, + "Backflip": 3626188, + "Side Flip": 3626189, + "Wall Kick": 3626190, + "Dive": 3626191, + "Ground Pound": 3626192, + "Kick": 3626193, + "Climb": 3626194, + "Ledge Grab": 3626195 +} + + cannon_item_table = { "Cannon Unlock BoB": 3626200, "Cannon Unlock WF": 3626201, @@ -29,4 +44,4 @@ class SM64Item(Item): "Cannon Unlock RR": 3626214 } -item_table = {**generic_item_table, **cannon_item_table} \ No newline at end of file +item_table = {**generic_item_table, **action_item_table, **cannon_item_table} \ No newline at end of file diff --git a/worlds/sm64ex/Options.py b/worlds/sm64ex/Options.py index 8a10f3edea5..d9a877df2b3 100644 --- a/worlds/sm64ex/Options.py +++ b/worlds/sm64ex/Options.py @@ -1,9 +1,10 @@ import typing from Options import Option, DefaultOnToggle, Range, Toggle, DeathLink, Choice - +from .Items import action_item_table class EnableCoinStars(DefaultOnToggle): - """Disable to Ignore 100 Coin Stars. You can still collect them, but they don't do anything""" + """Disable to Ignore 100 Coin Stars. You can still collect them, but they don't do anything. + Removes 15 locations from the pool.""" display_name = "Enable 100 Coin Stars" @@ -13,56 +14,63 @@ class StrictCapRequirements(DefaultOnToggle): class StrictCannonRequirements(DefaultOnToggle): - """If disabled, Stars that expect cannons may have to be acquired without them. Only makes a difference if Buddy - Checks are enabled""" + """If disabled, Stars that expect cannons may have to be acquired without them. + Has no effect if Buddy Checks and Move Randomizer are disabled""" display_name = "Strict Cannon Requirements" class FirstBowserStarDoorCost(Range): - """How many stars are required at the Star Door to Bowser in the Dark World""" + """What percent of the total stars are required at the Star Door to Bowser in the Dark World""" + display_name = "First Star Door Cost %" range_start = 0 - range_end = 50 - default = 8 + range_end = 40 + default = 7 class BasementStarDoorCost(Range): - """How many stars are required at the Star Door in the Basement""" + """What percent of the total stars are required at the Star Door in the Basement""" + display_name = "Basement Star Door %" range_start = 0 - range_end = 70 - default = 30 + range_end = 50 + default = 25 class SecondFloorStarDoorCost(Range): - """How many stars are required to access the third floor""" + """What percent of the total stars are required to access the third floor""" + display_name = 'Second Floor Star Door %' range_start = 0 - range_end = 90 - default = 50 + range_end = 70 + default = 42 class MIPS1Cost(Range): - """How many stars are required to spawn MIPS the first time""" + """What percent of the total stars are required to spawn MIPS the first time""" + display_name = "MIPS 1 Star %" range_start = 0 - range_end = 40 - default = 15 + range_end = 35 + default = 12 class MIPS2Cost(Range): - """How many stars are required to spawn MIPS the second time.""" + """What percent of the total stars are required to spawn MIPS the second time.""" + display_name = "MIPS 2 Star %" range_start = 0 - range_end = 80 - default = 50 + range_end = 70 + default = 42 class StarsToFinish(Range): - """How many stars are required at the infinite stairs""" - display_name = "Endless Stairs Stars" + """What percent of the total stars are required at the infinite stairs""" + display_name = "Endless Stairs Star %" range_start = 0 - range_end = 100 - default = 70 + range_end = 90 + default = 58 class AmountOfStars(Range): - """How many stars exist. Disabling 100 Coin Stars removes 15 from the Pool. At least max of any Cost set""" + """How many stars exist. + If there aren't enough locations to hold the given total, the total will be reduced.""" + display_name = "Total Power Stars" range_start = 35 range_end = 120 default = 120 @@ -83,11 +91,13 @@ class BuddyChecks(Toggle): class ExclamationBoxes(Choice): - """Include 1Up Exclamation Boxes during randomization""" + """Include 1Up Exclamation Boxes during randomization. + Adds 29 locations to the pool.""" display_name = "Randomize 1Up !-Blocks" option_Off = 0 option_1Ups_Only = 1 + class CompletionType(Choice): """Set goal for game completion""" display_name = "Completion Goal" @@ -96,17 +106,32 @@ class CompletionType(Choice): class ProgressiveKeys(DefaultOnToggle): - """Keys will first grant you access to the Basement, then to the Secound Floor""" + """Keys will first grant you access to the Basement, then to the Second Floor""" display_name = "Progressive Keys" +class StrictMoveRequirements(DefaultOnToggle): + """If disabled, Stars that expect certain moves may have to be acquired without them. Only makes a difference + if Move Randomization is enabled""" + display_name = "Strict Move Requirements" + +def getMoveRandomizerOption(action: str): + class MoveRandomizerOption(Toggle): + """Mario is unable to perform this action until a corresponding item is picked up. + This option is incompatible with builds using a 'nomoverando' branch.""" + display_name = f"Randomize {action}" + return MoveRandomizerOption + sm64_options: typing.Dict[str, type(Option)] = { "AreaRandomizer": AreaRandomizer, + "BuddyChecks": BuddyChecks, + "ExclamationBoxes": ExclamationBoxes, "ProgressiveKeys": ProgressiveKeys, "EnableCoinStars": EnableCoinStars, - "AmountOfStars": AmountOfStars, "StrictCapRequirements": StrictCapRequirements, "StrictCannonRequirements": StrictCannonRequirements, + "StrictMoveRequirements": StrictMoveRequirements, + "AmountOfStars": AmountOfStars, "FirstBowserStarDoorCost": FirstBowserStarDoorCost, "BasementStarDoorCost": BasementStarDoorCost, "SecondFloorStarDoorCost": SecondFloorStarDoorCost, @@ -114,7 +139,10 @@ class ProgressiveKeys(DefaultOnToggle): "MIPS2Cost": MIPS2Cost, "StarsToFinish": StarsToFinish, "death_link": DeathLink, - "BuddyChecks": BuddyChecks, - "ExclamationBoxes": ExclamationBoxes, - "CompletionType" : CompletionType, + "CompletionType": CompletionType, } + +for action in action_item_table: + # HACK: Disable randomization of double jump + if action == 'Double Jump': continue + sm64_options[f"MoveRandomizer{action.replace(' ','')}"] = getMoveRandomizerOption(action) diff --git a/worlds/sm64ex/Regions.py b/worlds/sm64ex/Regions.py index d426804c30f..c04b862fa75 100644 --- a/worlds/sm64ex/Regions.py +++ b/worlds/sm64ex/Regions.py @@ -1,5 +1,4 @@ import typing - from enum import Enum from BaseClasses import MultiWorld, Region, Entrance, Location @@ -8,7 +7,8 @@ locHMC_table, locLLL_table, locSSL_table, locDDD_table, locSL_table, \ locWDW_table, locTTM_table, locTHI_table, locTTC_table, locRR_table, \ locPSS_table, locSA_table, locBitDW_table, locTotWC_table, locCotMC_table, \ - locVCutM_table, locBitFS_table, locWMotR_table, locBitS_table, locSS_table + locVCutM_table, locBitFS_table, locWMotR_table, locBitS_table, locSS_table + class SM64Levels(int, Enum): BOB_OMB_BATTLEFIELD = 91 @@ -55,7 +55,7 @@ class SM64Levels(int, Enum): SM64Levels.TICK_TOCK_CLOCK: "Tick Tock Clock", SM64Levels.RAINBOW_RIDE: "Rainbow Ride" } -sm64_paintings_to_level = { painting: level for (level,painting) in sm64_level_to_paintings.items() } +sm64_paintings_to_level = {painting: level for (level, painting) in sm64_level_to_paintings.items() } # sm64secrets is a dict of secret areas, same format as sm64paintings sm64_level_to_secrets: typing.Dict[SM64Levels, str] = { @@ -68,152 +68,163 @@ class SM64Levels(int, Enum): SM64Levels.BOWSER_IN_THE_FIRE_SEA: "Bowser in the Fire Sea", SM64Levels.WING_MARIO_OVER_THE_RAINBOW: "Wing Mario over the Rainbow" } -sm64_secrets_to_level = { secret: level for (level,secret) in sm64_level_to_secrets.items() } +sm64_secrets_to_level = {secret: level for (level,secret) in sm64_level_to_secrets.items() } -sm64_entrances_to_level = { **sm64_paintings_to_level, **sm64_secrets_to_level } -sm64_level_to_entrances = { **sm64_level_to_paintings, **sm64_level_to_secrets } +sm64_entrances_to_level = {**sm64_paintings_to_level, **sm64_secrets_to_level } +sm64_level_to_entrances = {**sm64_level_to_paintings, **sm64_level_to_secrets } def create_regions(world: MultiWorld, player: int): regSS = Region("Menu", player, world, "Castle Area") - create_default_locs(regSS, locSS_table, player) + create_default_locs(regSS, locSS_table) world.regions.append(regSS) regBoB = create_region("Bob-omb Battlefield", player, world) - create_default_locs(regBoB, locBoB_table, player) + create_locs(regBoB, "BoB: Big Bob-Omb on the Summit", "BoB: Footrace with Koopa The Quick", + "BoB: Mario Wings to the Sky", "BoB: Behind Chain Chomp's Gate", "BoB: Bob-omb Buddy") + create_subregion(regBoB, "BoB: Island", "BoB: Shoot to the Island in the Sky", "BoB: Find the 8 Red Coins") if (world.EnableCoinStars[player].value): - regBoB.locations.append(SM64Location(player, "BoB: 100 Coins", location_table["BoB: 100 Coins"], regBoB)) - world.regions.append(regBoB) + create_locs(regBoB, "BoB: 100 Coins") regWhomp = create_region("Whomp's Fortress", player, world) - create_default_locs(regWhomp, locWhomp_table, player) + create_locs(regWhomp, "WF: Chip Off Whomp's Block", "WF: Shoot into the Wild Blue", "WF: Red Coins on the Floating Isle", + "WF: Fall onto the Caged Island", "WF: Blast Away the Wall") + create_subregion(regWhomp, "WF: Tower", "WF: To the Top of the Fortress", "WF: Bob-omb Buddy") if (world.EnableCoinStars[player].value): - regWhomp.locations.append(SM64Location(player, "WF: 100 Coins", location_table["WF: 100 Coins"], regWhomp)) - world.regions.append(regWhomp) + create_locs(regWhomp, "WF: 100 Coins") regJRB = create_region("Jolly Roger Bay", player, world) - create_default_locs(regJRB, locJRB_table, player) + create_locs(regJRB, "JRB: Plunder in the Sunken Ship", "JRB: Can the Eel Come Out to Play?", "JRB: Treasure of the Ocean Cave", + "JRB: Blast to the Stone Pillar", "JRB: Through the Jet Stream", "JRB: Bob-omb Buddy") + jrb_upper = create_subregion(regJRB, 'JRB: Upper', "JRB: Red Coins on the Ship Afloat") if (world.EnableCoinStars[player].value): - regJRB.locations.append(SM64Location(player, "JRB: 100 Coins", location_table["JRB: 100 Coins"], regJRB)) - world.regions.append(regJRB) + create_locs(jrb_upper, "JRB: 100 Coins") regCCM = create_region("Cool, Cool Mountain", player, world) - create_default_locs(regCCM, locCCM_table, player) + create_default_locs(regCCM, locCCM_table) if (world.EnableCoinStars[player].value): - regCCM.locations.append(SM64Location(player, "CCM: 100 Coins", location_table["CCM: 100 Coins"], regCCM)) - world.regions.append(regCCM) + create_locs(regCCM, "CCM: 100 Coins") regBBH = create_region("Big Boo's Haunt", player, world) - create_default_locs(regBBH, locBBH_table, player) + create_locs(regBBH, "BBH: Go on a Ghost Hunt", "BBH: Ride Big Boo's Merry-Go-Round", + "BBH: Secret of the Haunted Books", "BBH: Seek the 8 Red Coins") + bbh_third_floor = create_subregion(regBBH, "BBH: Third Floor", "BBH: Eye to Eye in the Secret Room") + create_subregion(bbh_third_floor, "BBH: Roof", "BBH: Big Boo's Balcony", "BBH: 1Up Block Top of Mansion") if (world.EnableCoinStars[player].value): - regBBH.locations.append(SM64Location(player, "BBH: 100 Coins", location_table["BBH: 100 Coins"], regBBH)) - world.regions.append(regBBH) + create_locs(regBBH, "BBH: 100 Coins") regPSS = create_region("The Princess's Secret Slide", player, world) - create_default_locs(regPSS, locPSS_table, player) - world.regions.append(regPSS) + create_default_locs(regPSS, locPSS_table) regSA = create_region("The Secret Aquarium", player, world) - create_default_locs(regSA, locSA_table, player) - world.regions.append(regSA) + create_default_locs(regSA, locSA_table) regTotWC = create_region("Tower of the Wing Cap", player, world) - create_default_locs(regTotWC, locTotWC_table, player) - world.regions.append(regTotWC) + create_default_locs(regTotWC, locTotWC_table) regBitDW = create_region("Bowser in the Dark World", player, world) - create_default_locs(regBitDW, locBitDW_table, player) - world.regions.append(regBitDW) + create_default_locs(regBitDW, locBitDW_table) - regBasement = create_region("Basement", player, world) - world.regions.append(regBasement) + create_region("Basement", player, world) regHMC = create_region("Hazy Maze Cave", player, world) - create_default_locs(regHMC, locHMC_table, player) + create_locs(regHMC, "HMC: Swimming Beast in the Cavern", "HMC: Metal-Head Mario Can Move!", + "HMC: Watch for Rolling Rocks", "HMC: Navigating the Toxic Maze","HMC: 1Up Block Past Rolling Rocks") + hmc_red_coin_area = create_subregion(regHMC, "HMC: Red Coin Area", "HMC: Elevate for 8 Red Coins") + create_subregion(regHMC, "HMC: Pit Islands", "HMC: A-Maze-Ing Emergency Exit", "HMC: 1Up Block above Pit") if (world.EnableCoinStars[player].value): - regHMC.locations.append(SM64Location(player, "HMC: 100 Coins", location_table["HMC: 100 Coins"], regHMC)) - world.regions.append(regHMC) + create_locs(hmc_red_coin_area, "HMC: 100 Coins") regLLL = create_region("Lethal Lava Land", player, world) - create_default_locs(regLLL, locLLL_table, player) + create_locs(regLLL, "LLL: Boil the Big Bully", "LLL: Bully the Bullies", + "LLL: 8-Coin Puzzle with 15 Pieces", "LLL: Red-Hot Log Rolling") + create_subregion(regLLL, "LLL: Upper Volcano", "LLL: Hot-Foot-It into the Volcano", "LLL: Elevator Tour in the Volcano") if (world.EnableCoinStars[player].value): - regLLL.locations.append(SM64Location(player, "LLL: 100 Coins", location_table["LLL: 100 Coins"], regLLL)) - world.regions.append(regLLL) + create_locs(regLLL, "LLL: 100 Coins") regSSL = create_region("Shifting Sand Land", player, world) - create_default_locs(regSSL, locSSL_table, player) + create_locs(regSSL, "SSL: In the Talons of the Big Bird", "SSL: Shining Atop the Pyramid", "SSL: Inside the Ancient Pyramid", + "SSL: Free Flying for 8 Red Coins", "SSL: Bob-omb Buddy", + "SSL: 1Up Block Outside Pyramid", "SSL: 1Up Block Pyramid Left Path", "SSL: 1Up Block Pyramid Back") + create_subregion(regSSL, "SSL: Upper Pyramid", "SSL: Stand Tall on the Four Pillars", "SSL: Pyramid Puzzle") if (world.EnableCoinStars[player].value): - regSSL.locations.append(SM64Location(player, "SSL: 100 Coins", location_table["SSL: 100 Coins"], regSSL)) - world.regions.append(regSSL) + create_locs(regSSL, "SSL: 100 Coins") regDDD = create_region("Dire, Dire Docks", player, world) - create_default_locs(regDDD, locDDD_table, player) + create_locs(regDDD, "DDD: Board Bowser's Sub", "DDD: Chests in the Current", "DDD: Through the Jet Stream", + "DDD: The Manta Ray's Reward", "DDD: Collect the Caps...") + ddd_moving_poles = create_subregion(regDDD, "DDD: Moving Poles", "DDD: Pole-Jumping for Red Coins") if (world.EnableCoinStars[player].value): - regDDD.locations.append(SM64Location(player, "DDD: 100 Coins", location_table["DDD: 100 Coins"], regDDD)) - world.regions.append(regDDD) + create_locs(ddd_moving_poles, "DDD: 100 Coins") regCotMC = create_region("Cavern of the Metal Cap", player, world) - create_default_locs(regCotMC, locCotMC_table, player) - world.regions.append(regCotMC) + create_default_locs(regCotMC, locCotMC_table) regVCutM = create_region("Vanish Cap under the Moat", player, world) - create_default_locs(regVCutM, locVCutM_table, player) - world.regions.append(regVCutM) + create_default_locs(regVCutM, locVCutM_table) regBitFS = create_region("Bowser in the Fire Sea", player, world) - create_default_locs(regBitFS, locBitFS_table, player) - world.regions.append(regBitFS) + create_subregion(regBitFS, "BitFS: Upper", *locBitFS_table.keys()) - regFloor2 = create_region("Second Floor", player, world) - world.regions.append(regFloor2) + create_region("Second Floor", player, world) regSL = create_region("Snowman's Land", player, world) - create_default_locs(regSL, locSL_table, player) + create_default_locs(regSL, locSL_table) if (world.EnableCoinStars[player].value): - regSL.locations.append(SM64Location(player, "SL: 100 Coins", location_table["SL: 100 Coins"], regSL)) - world.regions.append(regSL) + create_locs(regSL, "SL: 100 Coins") regWDW = create_region("Wet-Dry World", player, world) - create_default_locs(regWDW, locWDW_table, player) + create_locs(regWDW, "WDW: Express Elevator--Hurry Up!") + wdw_top = create_subregion(regWDW, "WDW: Top", "WDW: Shocking Arrow Lifts!", "WDW: Top o' the Town", + "WDW: Secrets in the Shallows & Sky", "WDW: Bob-omb Buddy") + create_subregion(regWDW, "WDW: Downtown", "WDW: Go to Town for Red Coins", "WDW: Quick Race Through Downtown!", "WDW: 1Up Block in Downtown") if (world.EnableCoinStars[player].value): - regWDW.locations.append(SM64Location(player, "WDW: 100 Coins", location_table["WDW: 100 Coins"], regWDW)) - world.regions.append(regWDW) + create_locs(wdw_top, "WDW: 100 Coins") regTTM = create_region("Tall, Tall Mountain", player, world) - create_default_locs(regTTM, locTTM_table, player) + ttm_middle = create_subregion(regTTM, "TTM: Middle", "TTM: Scary 'Shrooms, Red Coins", "TTM: Blast to the Lonely Mushroom", + "TTM: Bob-omb Buddy", "TTM: 1Up Block on Red Mushroom") + ttm_top = create_subregion(ttm_middle, "TTM: Top", "TTM: Scale the Mountain", "TTM: Mystery of the Monkey Cage", + "TTM: Mysterious Mountainside", "TTM: Breathtaking View from Bridge") if (world.EnableCoinStars[player].value): - regTTM.locations.append(SM64Location(player, "TTM: 100 Coins", location_table["TTM: 100 Coins"], regTTM)) - world.regions.append(regTTM) - - regTHIT = create_region("Tiny-Huge Island (Tiny)", player, world) - create_default_locs(regTHIT, locTHI_table, player) + create_locs(ttm_top, "TTM: 100 Coins") + + create_region("Tiny-Huge Island (Huge)", player, world) + create_region("Tiny-Huge Island (Tiny)", player, world) + regTHI = create_region("Tiny-Huge Island", player, world) + create_locs(regTHI, "THI: The Tip Top of the Huge Island", "THI: 1Up Block THI Small near Start") + thi_pipes = create_subregion(regTHI, "THI: Pipes", "THI: Pluck the Piranha Flower", "THI: Rematch with Koopa the Quick", + "THI: Five Itty Bitty Secrets", "THI: Wiggler's Red Coins", "THI: Bob-omb Buddy", + "THI: 1Up Block THI Large near Start", "THI: 1Up Block Windy Area") + thi_large_top = create_subregion(thi_pipes, "THI: Large Top", "THI: Make Wiggler Squirm") if (world.EnableCoinStars[player].value): - regTHIT.locations.append(SM64Location(player, "THI: 100 Coins", location_table["THI: 100 Coins"], regTHIT)) - world.regions.append(regTHIT) - regTHIH = create_region("Tiny-Huge Island (Huge)", player, world) - world.regions.append(regTHIH) + create_locs(thi_large_top, "THI: 100 Coins") regFloor3 = create_region("Third Floor", player, world) world.regions.append(regFloor3) regTTC = create_region("Tick Tock Clock", player, world) - create_default_locs(regTTC, locTTC_table, player) + create_locs(regTTC, "TTC: Stop Time for Red Coins") + ttc_lower = create_subregion(regTTC, "TTC: Lower", "TTC: Roll into the Cage", "TTC: Get a Hand", "TTC: 1Up Block Midway Up") + ttc_upper = create_subregion(ttc_lower, "TTC: Upper", "TTC: Timed Jumps on Moving Bars", "TTC: The Pit and the Pendulums") + ttc_top = create_subregion(ttc_upper, "TTC: Top", "TTC: Stomp on the Thwomp", "TTC: 1Up Block at the Top") if (world.EnableCoinStars[player].value): - regTTC.locations.append(SM64Location(player, "TTC: 100 Coins", location_table["TTC: 100 Coins"], regTTC)) - world.regions.append(regTTC) + create_locs(ttc_top, "TTC: 100 Coins") regRR = create_region("Rainbow Ride", player, world) - create_default_locs(regRR, locRR_table, player) + create_locs(regRR, "RR: Swingin' in the Breeze", "RR: Tricky Triangles!", + "RR: 1Up Block Top of Red Coin Maze", "RR: 1Up Block Under Fly Guy", "RR: Bob-omb Buddy") + rr_maze = create_subregion(regRR, "RR: Maze", "RR: Coins Amassed in a Maze") + create_subregion(regRR, "RR: Cruiser", "RR: Cruiser Crossing the Rainbow", "RR: Somewhere Over the Rainbow") + create_subregion(regRR, "RR: House", "RR: The Big House in the Sky", "RR: 1Up Block On House in the Sky") if (world.EnableCoinStars[player].value): - regRR.locations.append(SM64Location(player, "RR: 100 Coins", location_table["RR: 100 Coins"], regRR)) - world.regions.append(regRR) + create_locs(rr_maze, "RR: 100 Coins") regWMotR = create_region("Wing Mario over the Rainbow", player, world) - create_default_locs(regWMotR, locWMotR_table, player) - world.regions.append(regWMotR) + create_default_locs(regWMotR, locWMotR_table) regBitS = create_region("Bowser in the Sky", player, world) - create_default_locs(regBitS, locBitS_table, player) - world.regions.append(regBitS) + create_locs(regBitS, "Bowser in the Sky 1Up Block") + create_subregion(regBitS, "BitS: Top", "Bowser in the Sky Red Coins") def connect_regions(world: MultiWorld, player: int, source: str, target: str, rule=None): @@ -227,9 +238,30 @@ def connect_regions(world: MultiWorld, player: int, source: str, target: str, ru sourceRegion.exits.append(connection) connection.connect(targetRegion) + def create_region(name: str, player: int, world: MultiWorld) -> Region: - return Region(name, player, world) + region = Region(name, player, world) + world.regions.append(region) + return region + + +def create_subregion(source_region: Region, name: str, *locs: str) -> Region: + region = Region(name, source_region.player, source_region.multiworld) + connection = Entrance(source_region.player, name, source_region) + source_region.exits.append(connection) + connection.connect(region) + source_region.multiworld.regions.append(region) + create_locs(region, *locs) + return region + + +def set_subregion_access_rule(world, player, region_name: str, rule): + world.get_entrance(world, player, region_name).access_rule = rule + + +def create_default_locs(reg: Region, default_locs: dict): + create_locs(reg, *default_locs.keys()) + -def create_default_locs(reg: Region, locs, player): - reg_names = [name for name, id in locs.items()] - reg.locations += [SM64Location(player, loc_name, location_table[loc_name], reg) for loc_name in locs] +def create_locs(reg: Region, *locs: str): + reg.locations += [SM64Location(reg.player, loc_name, location_table[loc_name], reg) for loc_name in locs] diff --git a/worlds/sm64ex/Rules.py b/worlds/sm64ex/Rules.py index fedd5b7a6eb..f2b8e0bcdf2 100644 --- a/worlds/sm64ex/Rules.py +++ b/worlds/sm64ex/Rules.py @@ -1,36 +1,59 @@ -from ..generic.Rules import add_rule -from .Regions import connect_regions, SM64Levels, sm64_level_to_paintings, sm64_paintings_to_level, sm64_level_to_secrets, sm64_secrets_to_level, sm64_entrances_to_level, sm64_level_to_entrances +from typing import Callable, Union, Dict, Set -def shuffle_dict_keys(world, obj: dict) -> dict: - keys = list(obj.keys()) - values = list(obj.values()) +from BaseClasses import MultiWorld +from ..generic.Rules import add_rule, set_rule +from .Locations import location_table +from .Regions import connect_regions, SM64Levels, sm64_level_to_paintings, sm64_paintings_to_level,\ +sm64_level_to_secrets, sm64_secrets_to_level, sm64_entrances_to_level, sm64_level_to_entrances +from .Items import action_item_table + +def shuffle_dict_keys(world, dictionary: dict) -> dict: + keys = list(dictionary.keys()) + values = list(dictionary.values()) world.random.shuffle(keys) - return dict(zip(keys,values)) + return dict(zip(keys, values)) -def fix_reg(entrance_map: dict, entrance: SM64Levels, invalid_regions: set, - swapdict: dict, world): +def fix_reg(entrance_map: Dict[SM64Levels, str], entrance: SM64Levels, invalid_regions: Set[str], + swapdict: Dict[SM64Levels, str], world): if entrance_map[entrance] in invalid_regions: # Unlucky :C - replacement_regions = [(rand_region, rand_entrance) for rand_region, rand_entrance in swapdict.items() + replacement_regions = [(rand_entrance, rand_region) for rand_entrance, rand_region in swapdict.items() if rand_region not in invalid_regions] - rand_region, rand_entrance = world.random.choice(replacement_regions) + rand_entrance, rand_region = world.random.choice(replacement_regions) old_dest = entrance_map[entrance] entrance_map[entrance], entrance_map[rand_entrance] = rand_region, old_dest - swapdict[rand_region] = entrance - swapdict.pop(entrance_map[entrance]) # Entrance now fixed to rand_region + swapdict[entrance], swapdict[rand_entrance] = rand_region, old_dest + swapdict.pop(entrance) -def set_rules(world, player: int, area_connections: dict): +def set_rules(world, player: int, area_connections: dict, star_costs: dict, move_rando_bitvec: int): randomized_level_to_paintings = sm64_level_to_paintings.copy() randomized_level_to_secrets = sm64_level_to_secrets.copy() + valid_move_randomizer_start_courses = [ + "Bob-omb Battlefield", "Jolly Roger Bay", "Cool, Cool Mountain", + "Big Boo's Haunt", "Lethal Lava Land", "Shifting Sand Land", + "Dire, Dire Docks", "Snowman's Land" + ] # Excluding WF, HMC, WDW, TTM, THI, TTC, and RR if world.AreaRandomizer[player].value >= 1: # Some randomization is happening, randomize Courses randomized_level_to_paintings = shuffle_dict_keys(world,sm64_level_to_paintings) + # If not shuffling later, ensure a valid start course on move randomizer + if world.AreaRandomizer[player].value < 3 and move_rando_bitvec > 0: + swapdict = randomized_level_to_paintings.copy() + invalid_start_courses = {course for course in randomized_level_to_paintings.values() if course not in valid_move_randomizer_start_courses} + fix_reg(randomized_level_to_paintings, SM64Levels.BOB_OMB_BATTLEFIELD, invalid_start_courses, swapdict, world) + fix_reg(randomized_level_to_paintings, SM64Levels.WHOMPS_FORTRESS, invalid_start_courses, swapdict, world) + if world.AreaRandomizer[player].value == 2: # Randomize Secrets as well randomized_level_to_secrets = shuffle_dict_keys(world,sm64_level_to_secrets) - randomized_entrances = { **randomized_level_to_paintings, **randomized_level_to_secrets } + randomized_entrances = {**randomized_level_to_paintings, **randomized_level_to_secrets} if world.AreaRandomizer[player].value == 3: # Randomize Courses and Secrets in one pool - randomized_entrances = shuffle_dict_keys(world,randomized_entrances) - swapdict = { entrance: level for (level,entrance) in randomized_entrances.items() } + randomized_entrances = shuffle_dict_keys(world, randomized_entrances) # Guarantee first entrance is a course - fix_reg(randomized_entrances, SM64Levels.BOB_OMB_BATTLEFIELD, sm64_secrets_to_level.keys(), swapdict, world) + swapdict = randomized_entrances.copy() + if move_rando_bitvec == 0: + fix_reg(randomized_entrances, SM64Levels.BOB_OMB_BATTLEFIELD, sm64_secrets_to_level.keys(), swapdict, world) + else: + invalid_start_courses = {course for course in randomized_entrances.values() if course not in valid_move_randomizer_start_courses} + fix_reg(randomized_entrances, SM64Levels.BOB_OMB_BATTLEFIELD, invalid_start_courses, swapdict, world) + fix_reg(randomized_entrances, SM64Levels.WHOMPS_FORTRESS, invalid_start_courses, swapdict, world) # Guarantee BITFS is not mapped to DDD fix_reg(randomized_entrances, SM64Levels.BOWSER_IN_THE_FIRE_SEA, {"Dire, Dire Docks"}, swapdict, world) # Guarantee COTMC is not mapped to HMC, cuz thats impossible. If BitFS -> HMC, also no COTMC -> DDD. @@ -43,27 +66,34 @@ def set_rules(world, player: int, area_connections: dict): # Cast to int to not rely on availability of SM64Levels enum. Will cause crash in MultiServer otherwise area_connections.update({int(entrance_lvl): int(sm64_entrances_to_level[destination]) for (entrance_lvl,destination) in randomized_entrances.items()}) randomized_entrances_s = {sm64_level_to_entrances[entrance_lvl]: destination for (entrance_lvl,destination) in randomized_entrances.items()} - + + rf = RuleFactory(world, player, move_rando_bitvec) + connect_regions(world, player, "Menu", randomized_entrances_s["Bob-omb Battlefield"]) connect_regions(world, player, "Menu", randomized_entrances_s["Whomp's Fortress"], lambda state: state.has("Power Star", player, 1)) connect_regions(world, player, "Menu", randomized_entrances_s["Jolly Roger Bay"], lambda state: state.has("Power Star", player, 3)) connect_regions(world, player, "Menu", randomized_entrances_s["Cool, Cool Mountain"], lambda state: state.has("Power Star", player, 3)) connect_regions(world, player, "Menu", randomized_entrances_s["Big Boo's Haunt"], lambda state: state.has("Power Star", player, 12)) connect_regions(world, player, "Menu", randomized_entrances_s["The Princess's Secret Slide"], lambda state: state.has("Power Star", player, 1)) - connect_regions(world, player, "Menu", randomized_entrances_s["The Secret Aquarium"], lambda state: state.has("Power Star", player, 3)) + connect_regions(world, player, randomized_entrances_s["Jolly Roger Bay"], randomized_entrances_s["The Secret Aquarium"], + rf.build_rule("SF/BF | TJ & LG | MOVELESS & TJ")) connect_regions(world, player, "Menu", randomized_entrances_s["Tower of the Wing Cap"], lambda state: state.has("Power Star", player, 10)) - connect_regions(world, player, "Menu", randomized_entrances_s["Bowser in the Dark World"], lambda state: state.has("Power Star", player, world.FirstBowserStarDoorCost[player].value)) + connect_regions(world, player, "Menu", randomized_entrances_s["Bowser in the Dark World"], + lambda state: state.has("Power Star", player, star_costs["FirstBowserDoorCost"])) connect_regions(world, player, "Menu", "Basement", lambda state: state.has("Basement Key", player) or state.has("Progressive Key", player, 1)) connect_regions(world, player, "Basement", randomized_entrances_s["Hazy Maze Cave"]) connect_regions(world, player, "Basement", randomized_entrances_s["Lethal Lava Land"]) connect_regions(world, player, "Basement", randomized_entrances_s["Shifting Sand Land"]) - connect_regions(world, player, "Basement", randomized_entrances_s["Dire, Dire Docks"], lambda state: state.has("Power Star", player, world.BasementStarDoorCost[player].value)) + connect_regions(world, player, "Basement", randomized_entrances_s["Dire, Dire Docks"], + lambda state: state.has("Power Star", player, star_costs["BasementDoorCost"])) connect_regions(world, player, "Hazy Maze Cave", randomized_entrances_s["Cavern of the Metal Cap"]) - connect_regions(world, player, "Basement", randomized_entrances_s["Vanish Cap under the Moat"]) - connect_regions(world, player, "Basement", randomized_entrances_s["Bowser in the Fire Sea"], lambda state: state.has("Power Star", player, world.BasementStarDoorCost[player].value) and - state.can_reach("DDD: Board Bowser's Sub", 'Location', player)) + connect_regions(world, player, "Basement", randomized_entrances_s["Vanish Cap under the Moat"], + rf.build_rule("GP")) + connect_regions(world, player, "Basement", randomized_entrances_s["Bowser in the Fire Sea"], + lambda state: state.has("Power Star", player, star_costs["BasementDoorCost"]) and + state.can_reach("DDD: Board Bowser's Sub", 'Location', player)) connect_regions(world, player, "Menu", "Second Floor", lambda state: state.has("Second Floor Key", player) or state.has("Progressive Key", player, 2)) @@ -72,66 +102,127 @@ def set_rules(world, player: int, area_connections: dict): connect_regions(world, player, "Second Floor", randomized_entrances_s["Tall, Tall Mountain"]) connect_regions(world, player, "Second Floor", randomized_entrances_s["Tiny-Huge Island (Tiny)"]) connect_regions(world, player, "Second Floor", randomized_entrances_s["Tiny-Huge Island (Huge)"]) - connect_regions(world, player, "Tiny-Huge Island (Tiny)", "Tiny-Huge Island (Huge)") - connect_regions(world, player, "Tiny-Huge Island (Huge)", "Tiny-Huge Island (Tiny)") + connect_regions(world, player, "Tiny-Huge Island (Tiny)", "Tiny-Huge Island") + connect_regions(world, player, "Tiny-Huge Island (Huge)", "Tiny-Huge Island") - connect_regions(world, player, "Second Floor", "Third Floor", lambda state: state.has("Power Star", player, world.SecondFloorStarDoorCost[player].value)) + connect_regions(world, player, "Second Floor", "Third Floor", lambda state: state.has("Power Star", player, star_costs["SecondFloorDoorCost"])) connect_regions(world, player, "Third Floor", randomized_entrances_s["Tick Tock Clock"]) connect_regions(world, player, "Third Floor", randomized_entrances_s["Rainbow Ride"]) connect_regions(world, player, "Third Floor", randomized_entrances_s["Wing Mario over the Rainbow"]) - connect_regions(world, player, "Third Floor", "Bowser in the Sky", lambda state: state.has("Power Star", player, world.StarsToFinish[player].value)) + connect_regions(world, player, "Third Floor", "Bowser in the Sky", lambda state: state.has("Power Star", player, star_costs["StarsToFinish"])) - #Special Rules for some Locations - add_rule(world.get_location("BoB: Mario Wings to the Sky", player), lambda state: state.has("Cannon Unlock BoB", player)) - add_rule(world.get_location("BBH: Eye to Eye in the Secret Room", player), lambda state: state.has("Vanish Cap", player)) - add_rule(world.get_location("DDD: Collect the Caps...", player), lambda state: state.has("Vanish Cap", player)) - add_rule(world.get_location("DDD: Pole-Jumping for Red Coins", player), lambda state: state.can_reach("Bowser in the Fire Sea", 'Region', player)) + # Course Rules + # Bob-omb Battlefield + rf.assign_rule("BoB: Island", "CANN | CANNLESS & WC & TJ | CAPLESS & CANNLESS & LJ") + rf.assign_rule("BoB: Mario Wings to the Sky", "CANN & WC | CAPLESS & CANN") + rf.assign_rule("BoB: Behind Chain Chomp's Gate", "GP | MOVELESS") + # Whomp's Fortress + rf.assign_rule("WF: Tower", "{{WF: Chip Off Whomp's Block}}") + rf.assign_rule("WF: Chip Off Whomp's Block", "GP") + rf.assign_rule("WF: Shoot into the Wild Blue", "WK & TJ/SF | CANN") + rf.assign_rule("WF: Fall onto the Caged Island", "CL & {WF: Tower} | MOVELESS & TJ | MOVELESS & LJ | MOVELESS & CANN") + rf.assign_rule("WF: Blast Away the Wall", "CANN | CANNLESS & LG") + # Jolly Roger Bay + rf.assign_rule("JRB: Upper", "TJ/BF/SF/WK | MOVELESS & LG") + rf.assign_rule("JRB: Red Coins on the Ship Afloat", "CL/CANN/TJ/BF/WK") + rf.assign_rule("JRB: Blast to the Stone Pillar", "CANN+CL | CANNLESS & MOVELESS | CANN & MOVELESS") + rf.assign_rule("JRB: Through the Jet Stream", "MC | CAPLESS") + # Cool, Cool Mountain + rf.assign_rule("CCM: Wall Kicks Will Work", "TJ/WK & CANN | CANNLESS & TJ/WK | MOVELESS") + # Big Boo's Haunt + rf.assign_rule("BBH: Third Floor", "WK+LG | MOVELESS & WK") + rf.assign_rule("BBH: Roof", "LJ | MOVELESS") + rf.assign_rule("BBH: Secret of the Haunted Books", "KK | MOVELESS") + rf.assign_rule("BBH: Seek the 8 Red Coins", "BF/WK/TJ/SF") + rf.assign_rule("BBH: Eye to Eye in the Secret Room", "VC") + # Haze Maze Cave + rf.assign_rule("HMC: Red Coin Area", "CL & WK/LG/BF/SF/TJ | MOVELESS & WK") + rf.assign_rule("HMC: Pit Islands", "TJ+CL | MOVELESS & WK & TJ/LJ | MOVELESS & WK+SF+LG") + rf.assign_rule("HMC: Metal-Head Mario Can Move!", "LJ+MC | CAPLESS & LJ+TJ | CAPLESS & MOVELESS & LJ/TJ/WK") + rf.assign_rule("HMC: Navigating the Toxic Maze", "WK/SF/BF/TJ") + rf.assign_rule("HMC: Watch for Rolling Rocks", "WK") + # Lethal Lava Land + rf.assign_rule("LLL: Upper Volcano", "CL") + # Shifting Sand Land + rf.assign_rule("SSL: Upper Pyramid", "CL & TJ/BF/SF/LG | MOVELESS") + rf.assign_rule("SSL: Free Flying for 8 Red Coins", "TJ/SF/BF & TJ+WC | TJ/SF/BF & CAPLESS | MOVELESS") + # Dire, Dire Docks + rf.assign_rule("DDD: Moving Poles", "CL & {{Bowser in the Fire Sea Key}} | TJ+DV+LG+WK & MOVELESS") + rf.assign_rule("DDD: Through the Jet Stream", "MC | CAPLESS") + rf.assign_rule("DDD: Collect the Caps...", "VC+MC | CAPLESS & VC") + # Snowman's Land + rf.assign_rule("SL: Snowman's Big Head", "BF/SF/CANN/TJ") + rf.assign_rule("SL: In the Deep Freeze", "WK/SF/LG/BF/CANN/TJ") + rf.assign_rule("SL: Into the Igloo", "VC & TJ/SF/BF/WK/LG | MOVELESS & VC") + # Wet-Dry World + rf.assign_rule("WDW: Top", "WK/TJ/SF/BF | MOVELESS") + rf.assign_rule("WDW: Downtown", "NAR & LG & TJ/SF/BF | CANN | MOVELESS & TJ+DV") + rf.assign_rule("WDW: Go to Town for Red Coins", "WK | MOVELESS & TJ") + rf.assign_rule("WDW: Quick Race Through Downtown!", "VC & WK/BF | VC & TJ+LG | MOVELESS & VC & TJ") + rf.assign_rule("WDW: Bob-omb Buddy", "TJ | SF+LG | NAR & BF/SF") + # Tall, Tall Mountain + rf.assign_rule("TTM: Top", "MOVELESS & TJ | LJ/DV & LG/KK | MOVELESS & WK & SF/LG | MOVELESS & KK/DV") + rf.assign_rule("TTM: Blast to the Lonely Mushroom", "CANN | CANNLESS & LJ | MOVELESS & CANNLESS") + # Tiny-Huge Island + rf.assign_rule("THI: Pipes", "NAR | LJ/TJ/DV/LG | MOVELESS & BF/SF/KK") + rf.assign_rule("THI: Large Top", "NAR | LJ/TJ/DV | MOVELESS") + rf.assign_rule("THI: Wiggler's Red Coins", "WK") + rf.assign_rule("THI: Make Wiggler Squirm", "GP | MOVELESS & DV") + # Tick Tock Clock + rf.assign_rule("TTC: Lower", "LG/TJ/SF/BF/WK") + rf.assign_rule("TTC: Upper", "CL | SF+WK") + rf.assign_rule("TTC: Top", "CL | SF+WK") + rf.assign_rule("TTC: Stomp on the Thwomp", "LG & TJ/SF/BF") + rf.assign_rule("TTC: Stop Time for Red Coins", "NAR | {TTC: Lower}") + # Rainbow Ride + rf.assign_rule("RR: Maze", "WK | LJ & SF/BF/TJ | MOVELESS & LG/TJ") + rf.assign_rule("RR: Bob-omb Buddy", "WK | MOVELESS & LG") + rf.assign_rule("RR: Swingin' in the Breeze", "LG/TJ/BF/SF") + rf.assign_rule("RR: Tricky Triangles!", "LG/TJ/BF/SF") + rf.assign_rule("RR: Cruiser", "WK/SF/BF/LG/TJ") + rf.assign_rule("RR: House", "TJ/SF/BF/LG") + rf.assign_rule("RR: Somewhere Over the Rainbow", "CANN") + # Cavern of the Metal Cap + rf.assign_rule("Cavern of the Metal Cap Red Coins", "MC | CAPLESS") + # Vanish Cap Under the Moat + rf.assign_rule("Vanish Cap Under the Moat Switch", "WK/TJ/BF/SF/LG | MOVELESS") + rf.assign_rule("Vanish Cap Under the Moat Red Coins", "TJ/BF/SF/LG/WK & VC | CAPLESS & WK") + # Bowser in the Fire Sea + rf.assign_rule("BitFS: Upper", "CL") + rf.assign_rule("Bowser in the Fire Sea Red Coins", "LG/WK") + rf.assign_rule("Bowser in the Fire Sea 1Up Block Near Poles", "LG/WK") + # Wing Mario Over the Rainbow + rf.assign_rule("Wing Mario Over the Rainbow Red Coins", "TJ+WC") + rf.assign_rule("Wing Mario Over the Rainbow 1Up Block", "TJ+WC") + # Bowser in the Sky + rf.assign_rule("BitS: Top", "CL+TJ | CL+SF+LG | MOVELESS & TJ+WK+LG") + # 100 Coin Stars if world.EnableCoinStars[player]: - add_rule(world.get_location("DDD: 100 Coins", player), lambda state: state.can_reach("Bowser in the Fire Sea", 'Region', player)) - add_rule(world.get_location("SL: Into the Igloo", player), lambda state: state.has("Vanish Cap", player)) - add_rule(world.get_location("WDW: Quick Race Through Downtown!", player), lambda state: state.has("Vanish Cap", player)) - add_rule(world.get_location("RR: Somewhere Over the Rainbow", player), lambda state: state.has("Cannon Unlock RR", player)) - - if world.AreaRandomizer[player] or world.StrictCannonRequirements[player]: - # If area rando is on, it may not be possible to modify WDW's starting water level, - # which would make it impossible to reach downtown area without the cannon. - add_rule(world.get_location("WDW: Quick Race Through Downtown!", player), lambda state: state.has("Cannon Unlock WDW", player)) - add_rule(world.get_location("WDW: Go to Town for Red Coins", player), lambda state: state.has("Cannon Unlock WDW", player)) - add_rule(world.get_location("WDW: 1Up Block in Downtown", player), lambda state: state.has("Cannon Unlock WDW", player)) - - if world.StrictCapRequirements[player]: - add_rule(world.get_location("BoB: Mario Wings to the Sky", player), lambda state: state.has("Wing Cap", player)) - add_rule(world.get_location("HMC: Metal-Head Mario Can Move!", player), lambda state: state.has("Metal Cap", player)) - add_rule(world.get_location("JRB: Through the Jet Stream", player), lambda state: state.has("Metal Cap", player)) - add_rule(world.get_location("SSL: Free Flying for 8 Red Coins", player), lambda state: state.has("Wing Cap", player)) - add_rule(world.get_location("DDD: Through the Jet Stream", player), lambda state: state.has("Metal Cap", player)) - add_rule(world.get_location("DDD: Collect the Caps...", player), lambda state: state.has("Metal Cap", player)) - add_rule(world.get_location("Vanish Cap Under the Moat Red Coins", player), lambda state: state.has("Vanish Cap", player)) - add_rule(world.get_location("Cavern of the Metal Cap Red Coins", player), lambda state: state.has("Metal Cap", player)) - if world.StrictCannonRequirements[player]: - add_rule(world.get_location("WF: Blast Away the Wall", player), lambda state: state.has("Cannon Unlock WF", player)) - add_rule(world.get_location("JRB: Blast to the Stone Pillar", player), lambda state: state.has("Cannon Unlock JRB", player)) - add_rule(world.get_location("CCM: Wall Kicks Will Work", player), lambda state: state.has("Cannon Unlock CCM", player)) - add_rule(world.get_location("TTM: Blast to the Lonely Mushroom", player), lambda state: state.has("Cannon Unlock TTM", player)) - if world.StrictCapRequirements[player] and world.StrictCannonRequirements[player]: - # Ability to reach the floating island. Need some of those coins to get 100 coin star as well. - add_rule(world.get_location("BoB: Find the 8 Red Coins", player), lambda state: state.has("Cannon Unlock BoB", player) or state.has("Wing Cap", player)) - add_rule(world.get_location("BoB: Shoot to the Island in the Sky", player), lambda state: state.has("Cannon Unlock BoB", player) or state.has("Wing Cap", player)) - if world.EnableCoinStars[player]: - add_rule(world.get_location("BoB: 100 Coins", player), lambda state: state.has("Cannon Unlock BoB", player) or state.has("Wing Cap", player)) - - #Rules for Secret Stars - add_rule(world.get_location("Wing Mario Over the Rainbow Red Coins", player), lambda state: state.has("Wing Cap", player)) - add_rule(world.get_location("Wing Mario Over the Rainbow 1Up Block", player), lambda state: state.has("Wing Cap", player)) + rf.assign_rule("BoB: 100 Coins", "CANN & WC | CANNLESS & WC & TJ") + rf.assign_rule("WF: 100 Coins", "GP | MOVELESS") + rf.assign_rule("JRB: 100 Coins", "GP & {JRB: Upper}") + rf.assign_rule("HMC: 100 Coins", "GP") + rf.assign_rule("SSL: 100 Coins", "{SSL: Upper Pyramid} | GP") + rf.assign_rule("DDD: 100 Coins", "GP") + rf.assign_rule("SL: 100 Coins", "VC | MOVELESS") + rf.assign_rule("WDW: 100 Coins", "GP | {WDW: Downtown}") + rf.assign_rule("TTC: 100 Coins", "GP") + rf.assign_rule("THI: 100 Coins", "GP") + rf.assign_rule("RR: 100 Coins", "GP & WK") + # Castle Stars add_rule(world.get_location("Toad (Basement)", player), lambda state: state.can_reach("Basement", 'Region', player) and state.has("Power Star", player, 12)) add_rule(world.get_location("Toad (Second Floor)", player), lambda state: state.can_reach("Second Floor", 'Region', player) and state.has("Power Star", player, 25)) add_rule(world.get_location("Toad (Third Floor)", player), lambda state: state.can_reach("Third Floor", 'Region', player) and state.has("Power Star", player, 35)) - if world.MIPS1Cost[player].value > world.MIPS2Cost[player].value: - (world.MIPS2Cost[player].value, world.MIPS1Cost[player].value) = (world.MIPS1Cost[player].value, world.MIPS2Cost[player].value) - add_rule(world.get_location("MIPS 1", player), lambda state: state.can_reach("Basement", 'Region', player) and state.has("Power Star", player, world.MIPS1Cost[player].value)) - add_rule(world.get_location("MIPS 2", player), lambda state: state.can_reach("Basement", 'Region', player) and state.has("Power Star", player, world.MIPS2Cost[player].value)) + if star_costs["MIPS1Cost"] > star_costs["MIPS2Cost"]: + (star_costs["MIPS2Cost"], star_costs["MIPS1Cost"]) = (star_costs["MIPS1Cost"], star_costs["MIPS2Cost"]) + rf.assign_rule("MIPS 1", "DV | MOVELESS") + rf.assign_rule("MIPS 2", "DV | MOVELESS") + add_rule(world.get_location("MIPS 1", player), lambda state: state.can_reach("Basement", 'Region', player) and state.has("Power Star", player, star_costs["MIPS1Cost"])) + add_rule(world.get_location("MIPS 2", player), lambda state: state.can_reach("Basement", 'Region', player) and state.has("Power Star", player, star_costs["MIPS2Cost"])) + + world.completion_condition[player] = lambda state: state.can_reach("BitS: Top", 'Region', player) if world.CompletionType[player] == "last_bowser_stage": world.completion_condition[player] = lambda state: state.can_reach("Bowser in the Sky", 'Region', player) @@ -139,3 +230,145 @@ def set_rules(world, player: int, area_connections: dict): world.completion_condition[player] = lambda state: state.can_reach("Bowser in the Dark World", 'Region', player) and \ state.can_reach("Bowser in the Fire Sea", 'Region', player) and \ state.can_reach("Bowser in the Sky", 'Region', player) + + +class RuleFactory: + + world: MultiWorld + player: int + move_rando_bitvec: bool + area_randomizer: bool + capless: bool + cannonless: bool + moveless: bool + + token_table = { + "TJ": "Triple Jump", + "LJ": "Long Jump", + "BF": "Backflip", + "SF": "Side Flip", + "WK": "Wall Kick", + "DV": "Dive", + "GP": "Ground Pound", + "KK": "Kick", + "CL": "Climb", + "LG": "Ledge Grab", + "WC": "Wing Cap", + "MC": "Metal Cap", + "VC": "Vanish Cap" + } + + class SM64LogicException(Exception): + pass + + def __init__(self, world, player, move_rando_bitvec): + self.world = world + self.player = player + self.move_rando_bitvec = move_rando_bitvec + self.area_randomizer = world.AreaRandomizer[player].value > 0 + self.capless = not world.StrictCapRequirements[player] + self.cannonless = not world.StrictCannonRequirements[player] + self.moveless = not world.StrictMoveRequirements[player] or not move_rando_bitvec > 0 + + def assign_rule(self, target_name: str, rule_expr: str): + target = self.world.get_location(target_name, self.player) if target_name in location_table else self.world.get_entrance(target_name, self.player) + cannon_name = "Cannon Unlock " + target_name.split(':')[0] + try: + rule = self.build_rule(rule_expr, cannon_name) + except RuleFactory.SM64LogicException as exception: + raise RuleFactory.SM64LogicException( + f"Error generating rule for {target_name} using rule expression {rule_expr}: {exception}") + if rule: + set_rule(target, rule) + + def build_rule(self, rule_expr: str, cannon_name: str = '') -> Callable: + expressions = rule_expr.split(" | ") + rules = [] + for expression in expressions: + or_clause = self.combine_and_clauses(expression, cannon_name) + if or_clause is True: + return None + if or_clause is not False: + rules.append(or_clause) + if rules: + if len(rules) == 1: + return rules[0] + else: + return lambda state: any(rule(state) for rule in rules) + else: + return None + + def combine_and_clauses(self, rule_expr: str, cannon_name: str) -> Union[Callable, bool]: + expressions = rule_expr.split(" & ") + rules = [] + for expression in expressions: + and_clause = self.make_lambda(expression, cannon_name) + if and_clause is False: + return False + if and_clause is not True: + rules.append(and_clause) + if rules: + if len(rules) == 1: + return rules[0] + return lambda state: all(rule(state) for rule in rules) + else: + return True + + def make_lambda(self, expression: str, cannon_name: str) -> Union[Callable, bool]: + if '+' in expression: + tokens = expression.split('+') + items = set() + for token in tokens: + item = self.parse_token(token, cannon_name) + if item is True: + continue + if item is False: + return False + items.add(item) + if items: + return lambda state: state.has_all(items, self.player) + else: + return True + if '/' in expression: + tokens = expression.split('/') + items = set() + for token in tokens: + item = self.parse_token(token, cannon_name) + if item is True: + return True + if item is False: + continue + items.add(item) + if items: + return lambda state: state.has_any(items, self.player) + else: + return False + if '{{' in expression: + return lambda state: state.can_reach(expression[2:-2], "Location", self.player) + if '{' in expression: + return lambda state: state.can_reach(expression[1:-1], "Region", self.player) + item = self.parse_token(expression, cannon_name) + if item in (True, False): + return item + return lambda state: state.has(item, self.player) + + def parse_token(self, token: str, cannon_name: str) -> Union[str, bool]: + if token == "CANN": + return cannon_name + if token == "CAPLESS": + return self.capless + if token == "CANNLESS": + return self.cannonless + if token == "MOVELESS": + return self.moveless + if token == "NAR": + return not self.area_randomizer + item = self.token_table.get(token, None) + if not item: + raise Exception(f"Invalid token: '{item}'") + if item in action_item_table: + if self.move_rando_bitvec & (1 << (action_item_table[item] - action_item_table['Double Jump'])) == 0: + # This action item is not randomized. + return True + return item + diff --git a/worlds/sm64ex/__init__.py b/worlds/sm64ex/__init__.py index ab7409a324c..e54a4b7a910 100644 --- a/worlds/sm64ex/__init__.py +++ b/worlds/sm64ex/__init__.py @@ -1,7 +1,7 @@ import typing import os import json -from .Items import item_table, cannon_item_table, SM64Item +from .Items import item_table, action_item_table, cannon_item_table, SM64Item from .Locations import location_table, SM64Location from .Options import sm64_options from .Rules import set_rules @@ -35,14 +35,44 @@ class SM64World(World): item_name_to_id = item_table location_name_to_id = location_table - data_version = 8 + data_version = 9 required_client_version = (0, 3, 5) area_connections: typing.Dict[int, int] option_definitions = sm64_options + number_of_stars: int + move_rando_bitvec: int + filler_count: int + star_costs: typing.Dict[str, int] + def generate_early(self): + max_stars = 120 + if (not self.multiworld.EnableCoinStars[self.player].value): + max_stars -= 15 + self.move_rando_bitvec = 0 + for action, itemid in action_item_table.items(): + # HACK: Disable randomization of double jump + if action == 'Double Jump': continue + if getattr(self.multiworld, f"MoveRandomizer{action.replace(' ','')}")[self.player].value: + max_stars -= 1 + self.move_rando_bitvec |= (1 << (itemid - action_item_table['Double Jump'])) + if (self.multiworld.ExclamationBoxes[self.player].value > 0): + max_stars += 29 + self.number_of_stars = min(self.multiworld.AmountOfStars[self.player].value, max_stars) + self.filler_count = max_stars - self.number_of_stars + self.star_costs = { + 'FirstBowserDoorCost': round(self.multiworld.FirstBowserStarDoorCost[self.player].value * self.number_of_stars / 100), + 'BasementDoorCost': round(self.multiworld.BasementStarDoorCost[self.player].value * self.number_of_stars / 100), + 'SecondFloorDoorCost': round(self.multiworld.SecondFloorStarDoorCost[self.player].value * self.number_of_stars / 100), + 'MIPS1Cost': round(self.multiworld.MIPS1Cost[self.player].value * self.number_of_stars / 100), + 'MIPS2Cost': round(self.multiworld.MIPS2Cost[self.player].value * self.number_of_stars / 100), + 'StarsToFinish': round(self.multiworld.StarsToFinish[self.player].value * self.number_of_stars / 100) + } + # Nudge MIPS 1 to match vanilla on default percentage + if self.number_of_stars == 120 and self.multiworld.MIPS1Cost[self.player].value == 12: + self.star_costs['MIPS1Cost'] = 15 self.topology_present = self.multiworld.AreaRandomizer[self.player].value def create_regions(self): @@ -50,7 +80,7 @@ def create_regions(self): def set_rules(self): self.area_connections = {} - set_rules(self.multiworld, self.player, self.area_connections) + set_rules(self.multiworld, self.player, self.area_connections, self.star_costs, self.move_rando_bitvec) if self.topology_present: # Write area_connections to spoiler log for entrance, destination in self.area_connections.items(): @@ -72,31 +102,29 @@ def create_item(self, name: str) -> Item: return item def create_items(self): - starcount = self.multiworld.AmountOfStars[self.player].value - if (not self.multiworld.EnableCoinStars[self.player].value): - starcount = max(35,self.multiworld.AmountOfStars[self.player].value-15) - starcount = max(starcount, self.multiworld.FirstBowserStarDoorCost[self.player].value, - self.multiworld.BasementStarDoorCost[self.player].value, self.multiworld.SecondFloorStarDoorCost[self.player].value, - self.multiworld.MIPS1Cost[self.player].value, self.multiworld.MIPS2Cost[self.player].value, - self.multiworld.StarsToFinish[self.player].value) - self.multiworld.itempool += [self.create_item("Power Star") for i in range(0,starcount)] - self.multiworld.itempool += [self.create_item("1Up Mushroom") for i in range(starcount,120 - (15 if not self.multiworld.EnableCoinStars[self.player].value else 0))] - + # 1Up Mushrooms + self.multiworld.itempool += [self.create_item("1Up Mushroom") for i in range(0,self.filler_count)] + # Power Stars + self.multiworld.itempool += [self.create_item("Power Star") for i in range(0,self.number_of_stars)] + # Keys if (not self.multiworld.ProgressiveKeys[self.player].value): key1 = self.create_item("Basement Key") key2 = self.create_item("Second Floor Key") self.multiworld.itempool += [key1, key2] else: self.multiworld.itempool += [self.create_item("Progressive Key") for i in range(0,2)] - - wingcap = self.create_item("Wing Cap") - metalcap = self.create_item("Metal Cap") - vanishcap = self.create_item("Vanish Cap") - self.multiworld.itempool += [wingcap, metalcap, vanishcap] - + # Caps + self.multiworld.itempool += [self.create_item(cap_name) for cap_name in ["Wing Cap", "Metal Cap", "Vanish Cap"]] + # Cannons if (self.multiworld.BuddyChecks[self.player].value): self.multiworld.itempool += [self.create_item(name) for name, id in cannon_item_table.items()] - else: + # Moves + self.multiworld.itempool += [self.create_item(action) + for action, itemid in action_item_table.items() + if self.move_rando_bitvec & (1 << itemid - action_item_table['Double Jump'])] + + def generate_basic(self): + if not (self.multiworld.BuddyChecks[self.player].value): self.multiworld.get_location("BoB: Bob-omb Buddy", self.player).place_locked_item(self.create_item("Cannon Unlock BoB")) self.multiworld.get_location("WF: Bob-omb Buddy", self.player).place_locked_item(self.create_item("Cannon Unlock WF")) self.multiworld.get_location("JRB: Bob-omb Buddy", self.player).place_locked_item(self.create_item("Cannon Unlock JRB")) @@ -108,9 +136,7 @@ def create_items(self): self.multiworld.get_location("THI: Bob-omb Buddy", self.player).place_locked_item(self.create_item("Cannon Unlock THI")) self.multiworld.get_location("RR: Bob-omb Buddy", self.player).place_locked_item(self.create_item("Cannon Unlock RR")) - if (self.multiworld.ExclamationBoxes[self.player].value > 0): - self.multiworld.itempool += [self.create_item("1Up Mushroom") for i in range(0,29)] - else: + if (self.multiworld.ExclamationBoxes[self.player].value == 0): self.multiworld.get_location("CCM: 1Up Block Near Snowman", self.player).place_locked_item(self.create_item("1Up Mushroom")) self.multiworld.get_location("CCM: 1Up Block Ice Pillar", self.player).place_locked_item(self.create_item("1Up Mushroom")) self.multiworld.get_location("CCM: 1Up Block Secret Slide", self.player).place_locked_item(self.create_item("1Up Mushroom")) @@ -147,14 +173,10 @@ def get_filler_item_name(self) -> str: def fill_slot_data(self): return { "AreaRando": self.area_connections, - "FirstBowserDoorCost": self.multiworld.FirstBowserStarDoorCost[self.player].value, - "BasementDoorCost": self.multiworld.BasementStarDoorCost[self.player].value, - "SecondFloorDoorCost": self.multiworld.SecondFloorStarDoorCost[self.player].value, - "MIPS1Cost": self.multiworld.MIPS1Cost[self.player].value, - "MIPS2Cost": self.multiworld.MIPS2Cost[self.player].value, - "StarsToFinish": self.multiworld.StarsToFinish[self.player].value, + "MoveRandoVec": self.move_rando_bitvec, "DeathLink": self.multiworld.death_link[self.player].value, - "CompletionType" : self.multiworld.CompletionType[self.player].value, + "CompletionType": self.multiworld.CompletionType[self.player].value, + **self.star_costs } def generate_output(self, output_directory: str): From 55455914e6b2555db912d00797c57c3eee138365 Mon Sep 17 00:00:00 2001 From: BadMagic100 Date: Mon, 12 Feb 2024 22:48:33 -0800 Subject: [PATCH 33/38] CI: Add a workflow which automates some labeling (#2812) * Initial content-based labeling * Improve labeling rules around docs and /worlds/generic * Improve labeling rules around docs and webhost * Formatting * Update matching for webhost * back to square 1 on is:docu * Try a better glob for docs * Formatting * Manage PR state labels * Correct syntax for conditions * Correct syntax for conditions * add trigger on reopening * add trigger on closing * keep labels in sync as pr updates * Change edit event to sync * Restrict only to PRs to main * address review comments * apply only to PRs into main --- .github/labeler.yml | 30 ++++++++++++++++ .github/workflows/label-pull-requests.yml | 44 +++++++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 .github/labeler.yml create mode 100644 .github/workflows/label-pull-requests.yml diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 00000000000..c5829028366 --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,30 @@ +'is: documentation': +- changed-files: + - all-globs-to-all-files: '{**/docs/**,**/README.md}' + +'affects: webhost': +- changed-files: + - all-globs-to-any-file: 'WebHost.py' + - all-globs-to-any-file: 'WebHostLib/**/*' + +'affects: core': +- changed-files: + - all-globs-to-any-file: + - '!*Client.py' + - '!README.md' + - '!LICENSE' + - '!*.yml' + - '!.gitignore' + - '!**/docs/**' + - '!typings/kivy/**' + - '!test/**' + - '!data/**' + - '!.run/**' + - '!.github/**' + - '!worlds_disabled/**' + - '!worlds/**' + - '!WebHost.py' + - '!WebHostLib/**' + - any-glob-to-any-file: # exceptions to the above rules of "stuff that isn't core" + - 'worlds/generic/**/*.py' + - 'CommonClient.py' diff --git a/.github/workflows/label-pull-requests.yml b/.github/workflows/label-pull-requests.yml new file mode 100644 index 00000000000..42881aa49d9 --- /dev/null +++ b/.github/workflows/label-pull-requests.yml @@ -0,0 +1,44 @@ +name: Label Pull Request +on: + pull_request_target: + types: ['opened', 'reopened', 'synchronize', 'ready_for_review', 'converted_to_draft', 'closed'] + branches: ['main'] +permissions: + contents: read + pull-requests: write + +jobs: + labeler: + name: 'Apply content-based labels' + if: github.event.action == 'opened' || github.event.action == 'reopened' || github.event.action == 'synchronize' + runs-on: ubuntu-latest + steps: + - uses: actions/labeler@v5 + with: + sync-labels: true + peer_review: + name: 'Apply peer review label' + if: >- + (github.event.action == 'opened' || github.event.action == 'reopened' || + github.event.action == 'ready_for_review') && !github.event.pull_request.draft + runs-on: ubuntu-latest + steps: + - name: 'Add label' + run: "gh pr edit \"$PR_URL\" --add-label 'waiting-on: peer-review'" + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + unblock_draft_prs: + name: 'Remove waiting-on labels' + if: github.event.action == 'converted_to_draft' || github.event.action == 'closed' + runs-on: ubuntu-latest + steps: + - name: 'Remove labels' + run: |- + gh pr edit "$PR_URL" --remove-label 'waiting-on: peer-review' \ + --remove-label 'waiting-on: core-review' \ + --remove-label 'waiting-on: world-maintainer' \ + --remove-label 'waiting-on: author' + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 3ca34171721d7b8f8062010fe9f94eb83463940b Mon Sep 17 00:00:00 2001 From: Ishigh1 Date: Tue, 13 Feb 2024 22:46:18 +0100 Subject: [PATCH 34/38] LADX: Added some resilience to non-ASCII player names (#2642) * Added some resilience to non-ASCII player names or items * Also the client, not even sure if switching to ascii is useful here * Split a long line in two --- LinksAwakeningClient.py | 3 ++- worlds/ladx/LADXR/utils.py | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/LinksAwakeningClient.py b/LinksAwakeningClient.py index f3fc9d2cdb7..a51645feac9 100644 --- a/LinksAwakeningClient.py +++ b/LinksAwakeningClient.py @@ -348,7 +348,8 @@ async def wait_for_retroarch_connection(self): await asyncio.sleep(1.0) continue self.stop_bizhawk_spam = False - logger.info(f"Connected to Retroarch {version.decode('ascii')} running {rom_name.decode('ascii')}") + logger.info(f"Connected to Retroarch {version.decode('ascii', errors='replace')} " + f"running {rom_name.decode('ascii', errors='replace')}") return except (BlockingIOError, TimeoutError, ConnectionResetError): await asyncio.sleep(1.0) diff --git a/worlds/ladx/LADXR/utils.py b/worlds/ladx/LADXR/utils.py index fcf1d2bb56e..5f8b2685550 100644 --- a/worlds/ladx/LADXR/utils.py +++ b/worlds/ladx/LADXR/utils.py @@ -146,7 +146,7 @@ def setReplacementName(key: str, value: str) -> None: def formatText(instr: str, *, center: bool = False, ask: Optional[str] = None) -> bytes: instr = instr.format(**_NAMES) - s = instr.encode("ascii") + s = instr.encode("ascii", errors="replace") s = s.replace(b"'", b"^") def padLine(line: bytes) -> bytes: @@ -169,7 +169,7 @@ def padLine(line: bytes) -> bytes: if result_line: result += padLine(result_line) if ask is not None: - askbytes = ask.encode("ascii") + askbytes = ask.encode("ascii", errors="replace") result = result.rstrip() while len(result) % 32 != 16: result += b' ' From 74e79bff06612e312f0b9bbb52254a3bfa770c11 Mon Sep 17 00:00:00 2001 From: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com> Date: Tue, 13 Feb 2024 22:47:19 +0100 Subject: [PATCH 35/38] The Witness: Event System & Item Classification System revamp (#2652) Two things have been happening. **Incorrect Events** Spoiler logs containing events that just straight up have an incorrect name and shouldn't be there. E.g. "Symmetry Island Yellow 3 solved - Monastery Laser Activation" when playing Laser Shuffle where this event should not exist, because Laser Activations are governed by the Laser items. Now to be clear - There are no logic issues with it. The event will be in the spoiler log, but it won't actually be used in the way that its name suggests. Basically, every panel in the game has exactly one event name. If the panel is referenced by another panel, it will reference the event instead. So, the Symmetry Laser Panel location will reference Symmetry Island Yellow 3, and an event is created for Symmetry Island Yellow 3. The only problem is the **name**: The canonical name for the event is related to "Symmetry Island Yellow 3" is "Monastery Laser Activation", because that's another thing that panel does sometimes. From now on, event names are tied to both the panel referencing and the panel being referenced. Only once the referincing panel actually references the dependent panel (during the dependency reduction process in generate_early), is the event actually created. This also removes some spoiler log clutter where unused events were just in the location list. **Item classifications** When playing shuffle_doors, there are a lot of doors in the game that are logically useless depending on settings. When that happens, they should get downgraded from progression to useful. The previous system for this was jank and terrible. Now there is a better system for it, and many items have been added to it. :) --- worlds/witness/items.py | 28 +---- worlds/witness/locations.py | 2 +- worlds/witness/player_logic.py | 119 ++++++++++++++---- worlds/witness/rules.py | 2 +- .../Exclusions/Disable_Unrandomized.txt | 10 +- 5 files changed, 108 insertions(+), 53 deletions(-) diff --git a/worlds/witness/items.py b/worlds/witness/items.py index 3a8b35793a3..41bc3c1bb8d 100644 --- a/worlds/witness/items.py +++ b/worlds/witness/items.py @@ -112,30 +112,12 @@ def __init__(self, world: "WitnessWorld", logic: WitnessPlayerLogic, locat: Witn or name in logic.PROG_ITEMS_ACTUALLY_IN_THE_GAME } - # Adjust item classifications based on game settings. - eps_shuffled = self._world.options.shuffle_EPs - come_to_you = self._world.options.elevators_come_to_you - difficulty = self._world.options.puzzle_randomization + # Downgrade door items for item_name, item_data in self.item_data.items(): - if not eps_shuffled and item_name in {"Monastery Garden Entry (Door)", - "Monastery Shortcuts", - "Quarry Boathouse Hook Control (Panel)", - "Windmill Turn Control (Panel)"}: - # Downgrade doors that only gate progress in EP shuffle. - item_data.classification = ItemClassification.useful - elif not come_to_you and not eps_shuffled and item_name in {"Quarry Elevator Control (Panel)", - "Swamp Long Bridge (Panel)"}: - # These Bridges/Elevators are not logical access because they may leave you stuck. - item_data.classification = ItemClassification.useful - elif item_name in {"River Monastery Garden Shortcut (Door)", - "Monastery Laser Shortcut (Door)", - "Orchard Second Gate (Door)", - "Jungle Bamboo Laser Shortcut (Door)", - "Caves Elevator Controls (Panel)"}: - # Downgrade doors that don't gate progress. - item_data.classification = ItemClassification.useful - elif item_name == "Keep Pressure Plates 2 Exit (Door)" and not (difficulty == "none" and eps_shuffled): - # PP2EP requires the door in vanilla puzzles, otherwise it's unnecessary + if not isinstance(item_data.definition, DoorItemDefinition): + continue + + if all(not self._logic.solvability_guaranteed(e_hex) for e_hex in item_data.definition.panel_id_hexes): item_data.classification = ItemClassification.useful # Build the mandatory item list. diff --git a/worlds/witness/locations.py b/worlds/witness/locations.py index 1f73c2c031a..781cc4e25d9 100644 --- a/worlds/witness/locations.py +++ b/worlds/witness/locations.py @@ -543,7 +543,7 @@ def __init__(self, world: "WitnessWorld", player_logic: WitnessPlayerLogic): ) event_locations = { - p for p in player_logic.EVENT_PANELS + p for p in player_logic.USED_EVENT_NAMES_BY_HEX } self.EVENT_LOCATION_TABLE = { diff --git a/worlds/witness/player_logic.py b/worlds/witness/player_logic.py index 24dfa7d9c87..229da0a2879 100644 --- a/worlds/witness/player_logic.py +++ b/worlds/witness/player_logic.py @@ -101,8 +101,11 @@ def reduce_req_within_region(self, panel_hex: str) -> FrozenSet[FrozenSet[str]]: for option_entity in option: dep_obj = self.REFERENCE_LOGIC.ENTITIES_BY_HEX.get(option_entity) - if option_entity in self.EVENT_NAMES_BY_HEX: + if option_entity in self.ALWAYS_EVENT_NAMES_BY_HEX: new_items = frozenset({frozenset([option_entity])}) + elif (panel_hex, option_entity) in self.CONDITIONAL_EVENTS: + new_items = frozenset({frozenset([option_entity])}) + self.USED_EVENT_NAMES_BY_HEX[option_entity] = self.CONDITIONAL_EVENTS[(panel_hex, option_entity)] elif option_entity in {"7 Lasers", "11 Lasers", "7 Lasers + Redirect", "11 Lasers + Redirect", "PP2 Weirdness", "Theater to Tunnels"}: new_items = frozenset({frozenset([option_entity])}) @@ -170,14 +173,11 @@ def make_single_adjustment(self, adj_type: str, line: str): if adj_type == "Event Items": line_split = line.split(" - ") new_event_name = line_split[0] - hex_set = line_split[1].split(",") - - for entity, event_name in self.EVENT_NAMES_BY_HEX.items(): - if event_name == new_event_name: - self.DONT_MAKE_EVENTS.add(entity) + entity_hex = line_split[1] + dependent_hex_set = line_split[2].split(",") - for hex_code in hex_set: - self.EVENT_NAMES_BY_HEX[hex_code] = new_event_name + for dependent_hex in dependent_hex_set: + self.CONDITIONAL_EVENTS[(entity_hex, dependent_hex)] = new_event_name return @@ -437,7 +437,7 @@ def make_options_adjustments(self, world: "WitnessWorld"): obelisk = self.REFERENCE_LOGIC.ENTITIES_BY_HEX[self.REFERENCE_LOGIC.EP_TO_OBELISK_SIDE[ep_hex]] obelisk_name = obelisk["checkName"] ep_name = self.REFERENCE_LOGIC.ENTITIES_BY_HEX[ep_hex]["checkName"] - self.EVENT_NAMES_BY_HEX[ep_hex] = f"{obelisk_name} - {ep_name}" + self.ALWAYS_EVENT_NAMES_BY_HEX[ep_hex] = f"{obelisk_name} - {ep_name}" else: adjustment_linesets_in_order.append(["Disabled Locations:"] + get_ep_obelisks()[1:]) @@ -505,7 +505,8 @@ def make_dependency_reduced_checklist(self): for option in connection[1]: individual_entity_requirements = [] for entity in option: - if entity in self.EVENT_NAMES_BY_HEX or entity not in self.REFERENCE_LOGIC.ENTITIES_BY_HEX: + if (entity in self.ALWAYS_EVENT_NAMES_BY_HEX + or entity not in self.REFERENCE_LOGIC.ENTITIES_BY_HEX): individual_entity_requirements.append(frozenset({frozenset({entity})})) else: entity_req = self.reduce_req_within_region(entity) @@ -522,6 +523,72 @@ def make_dependency_reduced_checklist(self): self.CONNECTIONS_BY_REGION_NAME[region] = new_connections + def solvability_guaranteed(self, entity_hex: str): + return not ( + entity_hex in self.ENTITIES_WITHOUT_ENSURED_SOLVABILITY + or entity_hex in self.COMPLETELY_DISABLED_ENTITIES + or entity_hex in self.IRRELEVANT_BUT_NOT_DISABLED_ENTITIES + ) + + def determine_unrequired_entities(self, world: "WitnessWorld"): + """Figure out which major items are actually useless in this world's settings""" + + # Gather quick references to relevant options + eps_shuffled = world.options.shuffle_EPs + come_to_you = world.options.elevators_come_to_you + difficulty = world.options.puzzle_randomization + discards_shuffled = world.options.shuffle_discarded_panels + boat_shuffled = world.options.shuffle_boat + symbols_shuffled = world.options.shuffle_symbols + disable_non_randomized = world.options.disable_non_randomized_puzzles + postgame_included = world.options.shuffle_postgame + goal = world.options.victory_condition + doors = world.options.shuffle_doors + shortbox_req = world.options.mountain_lasers + longbox_req = world.options.challenge_lasers + + # Make some helper booleans so it is easier to follow what's going on + mountain_upper_is_in_postgame = ( + goal == "mountain_box_short" + or goal == "mountain_box_long" and longbox_req <= shortbox_req + ) + mountain_upper_included = postgame_included or not mountain_upper_is_in_postgame + remote_doors = doors >= 2 + door_panels = doors == "panels" or doors == "mixed" + + # It is easier to think about when these items *are* required, so we make that dict first + # If the entity is disabled anyway, we don't need to consider that case + is_item_required_dict = { + "0x03750": eps_shuffled, # Monastery Garden Entry Door + "0x275FA": eps_shuffled, # Boathouse Hook Control + "0x17D02": eps_shuffled, # Windmill Turn Control + "0x0368A": symbols_shuffled or door_panels, # Quarry Stoneworks Stairs Door + "0x3865F": symbols_shuffled or door_panels or eps_shuffled, # Quarry Boathouse 2nd Barrier + "0x17CC4": come_to_you or eps_shuffled, # Quarry Elevator Panel + "0x17E2B": come_to_you and boat_shuffled or eps_shuffled, # Swamp Long Bridge + "0x0CF2A": False, # Jungle Monastery Garden Shortcut + "0x17CAA": remote_doors, # Jungle Monastery Garden Shortcut Panel + "0x0364E": False, # Monastery Laser Shortcut Door + "0x03713": remote_doors, # Monastery Laser Shortcut Panel + "0x03313": False, # Orchard Second Gate + "0x337FA": remote_doors, # Jungle Bamboo Laser Shortcut Panel + "0x3873B": False, # Jungle Bamboo Laser Shortcut Door + "0x335AB": False, # Caves Elevator Controls + "0x335AC": False, # Caves Elevator Controls + "0x3369D": False, # Caves Elevator Controls + "0x01BEA": difficulty == "none" and eps_shuffled, # Keep PP2 + "0x0A0C9": eps_shuffled or discards_shuffled or disable_non_randomized, # Cargo Box Entry Door + "0x09EEB": discards_shuffled or mountain_upper_included, # Mountain Floor 2 Elevator Control Panel + "0x09EDD": mountain_upper_included, # Mountain Floor 2 Exit Door + "0x17CAB": symbols_shuffled or not disable_non_randomized or "0x17CAB" not in self.DOOR_ITEMS_BY_ID, + # Jungle Popup Wall Panel + } + + # Now, return the keys of the dict entries where the result is False to get unrequired major items + self.ENTITIES_WITHOUT_ENSURED_SOLVABILITY |= { + item_name for item_name, is_required in is_item_required_dict.items() if not is_required + } + def make_event_item_pair(self, panel: str): """ Makes a pair of an event panel and its event item @@ -529,21 +596,23 @@ def make_event_item_pair(self, panel: str): action = " Opened" if self.REFERENCE_LOGIC.ENTITIES_BY_HEX[panel]["entityType"] == "Door" else " Solved" name = self.REFERENCE_LOGIC.ENTITIES_BY_HEX[panel]["checkName"] + action - if panel not in self.EVENT_NAMES_BY_HEX: + if panel not in self.USED_EVENT_NAMES_BY_HEX: warning("Panel \"" + name + "\" does not have an associated event name.") - self.EVENT_NAMES_BY_HEX[panel] = name + " Event" - pair = (name, self.EVENT_NAMES_BY_HEX[panel]) + self.USED_EVENT_NAMES_BY_HEX[panel] = name + " Event" + pair = (name, self.USED_EVENT_NAMES_BY_HEX[panel]) return pair def make_event_panel_lists(self): - self.EVENT_NAMES_BY_HEX[self.VICTORY_LOCATION] = "Victory" + self.ALWAYS_EVENT_NAMES_BY_HEX[self.VICTORY_LOCATION] = "Victory" - for event_hex, event_name in self.EVENT_NAMES_BY_HEX.items(): - if event_hex in self.COMPLETELY_DISABLED_ENTITIES or event_hex in self.IRRELEVANT_BUT_NOT_DISABLED_ENTITIES: - continue - self.EVENT_PANELS.add(event_hex) + self.USED_EVENT_NAMES_BY_HEX.update(self.ALWAYS_EVENT_NAMES_BY_HEX) + + self.USED_EVENT_NAMES_BY_HEX = { + event_hex: event_name for event_hex, event_name in self.USED_EVENT_NAMES_BY_HEX.items() + if self.solvability_guaranteed(event_hex) + } - for panel in self.EVENT_PANELS: + for panel in self.USED_EVENT_NAMES_BY_HEX: pair = self.make_event_item_pair(panel) self.EVENT_ITEM_PAIRS[pair[0]] = pair[1] @@ -556,6 +625,8 @@ def __init__(self, world: "WitnessWorld", disabled_locations: Set[str], start_in self.IRRELEVANT_BUT_NOT_DISABLED_ENTITIES = set() + self.ENTITIES_WITHOUT_ENSURED_SOLVABILITY = set() + self.THEORETICAL_ITEMS = set() self.THEORETICAL_ITEMS_NO_MULTI = set() self.MULTI_AMOUNTS = defaultdict(lambda: 1) @@ -580,16 +651,14 @@ def __init__(self, world: "WitnessWorld", disabled_locations: Set[str], start_in # Determining which panels need to be events is a difficult process. # At the end, we will have EVENT_ITEM_PAIRS for all the necessary ones. - self.EVENT_PANELS = set() self.EVENT_ITEM_PAIRS = dict() - self.DONT_MAKE_EVENTS = set() self.COMPLETELY_DISABLED_ENTITIES = set() self.PRECOMPLETED_LOCATIONS = set() self.EXCLUDED_LOCATIONS = set() self.ADDED_CHECKS = set() self.VICTORY_LOCATION = "0x0356B" - self.EVENT_NAMES_BY_HEX = { + self.ALWAYS_EVENT_NAMES_BY_HEX = { "0x00509": "+1 Laser (Symmetry Laser)", "0x012FB": "+1 Laser (Desert Laser)", "0x09F98": "Desert Laser Redirection", @@ -602,10 +671,14 @@ def __init__(self, world: "WitnessWorld", disabled_locations: Set[str], start_in "0x0C2B2": "+1 Laser (Bunker Laser)", "0x00BF6": "+1 Laser (Swamp Laser)", "0x028A4": "+1 Laser (Treehouse Laser)", - "0x09F7F": "Mountain Entry", + "0x17C34": "Mountain Entry", "0xFFF00": "Bottom Floor Discard Turns On", } + self.USED_EVENT_NAMES_BY_HEX = {} + self.CONDITIONAL_EVENTS = {} + self.make_options_adjustments(world) + self.determine_unrequired_entities(world) self.make_dependency_reduced_checklist() self.make_event_panel_lists() diff --git a/worlds/witness/rules.py b/worlds/witness/rules.py index 5eded11ad41..8636829a4ef 100644 --- a/worlds/witness/rules.py +++ b/worlds/witness/rules.py @@ -170,7 +170,7 @@ def _has_item(item: str, world: "WitnessWorld", player: int, return lambda state: _can_do_expert_pp2(state, world) elif item == "Theater to Tunnels": return lambda state: _can_do_theater_to_tunnels(state, world) - if item in player_logic.EVENT_PANELS: + if item in player_logic.USED_EVENT_NAMES_BY_HEX: return _can_solve_panel(item, world, player, player_logic, locat) prog_item = StaticWitnessLogic.get_parent_progressive_item(item) diff --git a/worlds/witness/settings/Exclusions/Disable_Unrandomized.txt b/worlds/witness/settings/Exclusions/Disable_Unrandomized.txt index 2419bde06c1..09c366cfaab 100644 --- a/worlds/witness/settings/Exclusions/Disable_Unrandomized.txt +++ b/worlds/witness/settings/Exclusions/Disable_Unrandomized.txt @@ -1,9 +1,9 @@ Event Items: -Monastery Laser Activation - 0x00A5B,0x17CE7,0x17FA9 -Bunker Laser Activation - 0x00061,0x17D01,0x17C42 -Shadows Laser Activation - 0x00021,0x17D28,0x17C71 -Town Tower 4th Door Opens - 0x17CFB,0x3C12B,0x17CF7 -Jungle Popup Wall Lifts - 0x17FA0,0x17D27,0x17F9B,0x17CAB +Monastery Laser Activation - 0x17C65 - 0x00A5B,0x17CE7,0x17FA9 +Bunker Laser Activation - 0x0C2B2 - 0x00061,0x17D01,0x17C42 +Shadows Laser Activation - 0x181B3 - 0x00021,0x17D28,0x17C71 +Town Tower 4th Door Opens - 0x2779A - 0x17CFB,0x3C12B,0x17CF7 +Jungle Popup Wall Lifts - 0x1475B - 0x17FA0,0x17D27,0x17F9B,0x17CAB Requirement Changes: 0x17C65 - 0x00A5B | 0x17CE7 | 0x17FA9 From 57fcdf4fbe6778fe45db54309677f877df51fbf0 Mon Sep 17 00:00:00 2001 From: Bryce Wilson Date: Tue, 13 Feb 2024 14:47:57 -0700 Subject: [PATCH 36/38] Pokemon Emerald: Add missed locations to postgame locations group (#2654) --- worlds/pokemon_emerald/locations.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/worlds/pokemon_emerald/locations.py b/worlds/pokemon_emerald/locations.py index bfe5be75458..3d842ecbac9 100644 --- a/worlds/pokemon_emerald/locations.py +++ b/worlds/pokemon_emerald/locations.py @@ -114,8 +114,10 @@ def create_location_label_to_id_map() -> Dict[str, int]: "Littleroot Town - S.S. Ticket from Norman", "SS Tidal - Hidden Item in Lower Deck Trash Can", "SS Tidal - TM49 from Thief", + "Safari Zone NE - Item on Ledge", "Safari Zone NE - Hidden Item North", "Safari Zone NE - Hidden Item East", + "Safari Zone SE - Item in Grass", "Safari Zone SE - Hidden Item in South Grass 1", "Safari Zone SE - Hidden Item in South Grass 2", } From 2165253961e3c99ecae4c1c7c41112d9ae782ca9 Mon Sep 17 00:00:00 2001 From: Star Rauchenberger Date: Tue, 13 Feb 2024 19:55:19 -0500 Subject: [PATCH 37/38] Lingo: Detach Art Gallery Exit from Progressive Art Gallery (#2739) The final stage of Progressive Art Gallery opens up the four-way intersection between the Art Gallery, Orange Tower Fifth Floor, The Bearer, and Outside The Initiated. This is a very useful door, and it would be cool to be able to open it without having to get five progressive items. The original reason this was included in the progression was because getting into the back of Art Gallery early would cause sequence breaks. At this point, the way the client handles the Art Gallery has changed enough that it does not matter if the player can go through this door before getting all progressive art galleries. --- worlds/lingo/data/LL1.yaml | 2 +- worlds/lingo/test/TestProgressive.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/worlds/lingo/data/LL1.yaml b/worlds/lingo/data/LL1.yaml index da78a5123df..6d74a3f0da5 100644 --- a/worlds/lingo/data/LL1.yaml +++ b/worlds/lingo/data/LL1.yaml @@ -6193,6 +6193,7 @@ Exit: id: Tower Room Area Doors/Door_painting_exit include_reduce: True + item_name: Orange Tower Fifth Floor - Quadruple Intersection panels: - ONE ROAD MANY TURNS paintings: @@ -6212,7 +6213,6 @@ - Third Floor - Fourth Floor - Fifth Floor - - Exit Art Gallery (Second Floor): entrances: Art Gallery: diff --git a/worlds/lingo/test/TestProgressive.py b/worlds/lingo/test/TestProgressive.py index 8edc7ce6cce..0aaebe9319b 100644 --- a/worlds/lingo/test/TestProgressive.py +++ b/worlds/lingo/test/TestProgressive.py @@ -142,7 +142,7 @@ def test_item(self): self.assertTrue(self.can_reach_location("Art Gallery - ONE ROAD MANY TURNS")) self.assertFalse(self.multiworld.state.can_reach("Orange Tower Fifth Floor", "Region", self.player)) - self.collect(progressive_gallery_room[4]) + self.collect_by_name("Orange Tower Fifth Floor - Quadruple Intersection") self.assertTrue(self.multiworld.state.can_reach("Art Gallery", "Region", self.player)) self.assertTrue(self.multiworld.state.can_reach("Art Gallery (Second Floor)", "Region", self.player)) self.assertTrue(self.multiworld.state.can_reach("Art Gallery (Third Floor)", "Region", self.player)) From 2167db5a881870e7eaf34b8eccbf98957a50cd0b Mon Sep 17 00:00:00 2001 From: Star Rauchenberger Date: Tue, 13 Feb 2024 19:56:24 -0500 Subject: [PATCH 38/38] Lingo: Split up Color Hunt and Champion's Rest (#2745) --- worlds/lingo/data/LL1.yaml | 91 ++++++++++++++++-------------------- worlds/lingo/data/ids.yaml | 6 +-- worlds/lingo/player_logic.py | 5 +- 3 files changed, 46 insertions(+), 56 deletions(-) diff --git a/worlds/lingo/data/LL1.yaml b/worlds/lingo/data/LL1.yaml index 6d74a3f0da5..2e18766c017 100644 --- a/worlds/lingo/data/LL1.yaml +++ b/worlds/lingo/data/LL1.yaml @@ -1166,7 +1166,7 @@ group: Color Hunt Barriers skip_location: True panels: - - room: Champion's Rest + - room: Color Hunt panel: PURPLE Hallway Door: id: Red Blue Purple Room Area Doors/Door_room_2 @@ -1957,7 +1957,7 @@ group: Color Hunt Barriers skip_location: True panels: - - room: Champion's Rest + - room: Color Hunt panel: RED Rhyme Room Entrance: id: Double Room Area Doors/Door_room_entry_stairs2 @@ -1975,9 +1975,9 @@ - Color Arrow Room Doors/Door_orange_hider_1 - Color Arrow Room Doors/Door_orange_hider_2 - Color Arrow Room Doors/Door_orange_hider_3 - location_name: Color Hunt - RED and YELLOW - group: Champion's Rest - Color Barriers - item_name: Champion's Rest - Orange Barrier + location_name: Color Barriers - RED and YELLOW + group: Color Hunt Barriers + item_name: Color Hunt - Orange Barrier panels: - RED - room: Directional Gallery @@ -2382,7 +2382,7 @@ group: Color Hunt Barriers skip_location: True panels: - - room: Champion's Rest + - room: Color Hunt panel: GREEN paintings: - id: flower_painting_7 @@ -2893,14 +2893,14 @@ group: Color Hunt Barriers skip_location: True panels: - - room: Champion's Rest + - room: Color Hunt panel: BLUE Orange Barrier: id: Color Arrow Room Doors/Door_orange_3 group: Color Hunt Barriers skip_location: True panels: - - room: Champion's Rest + - room: Color Hunt panel: ORANGE Initiated Entrance: id: Red Blue Purple Room Area Doors/Door_locked_knocked @@ -2912,9 +2912,9 @@ # containing region. Green Barrier: id: Color Arrow Room Doors/Door_green_hider_1 - location_name: Color Hunt - BLUE and YELLOW - item_name: Champion's Rest - Green Barrier - group: Champion's Rest - Color Barriers + location_name: Color Barriers - BLUE and YELLOW + item_name: Color Hunt - Green Barrier + group: Color Hunt Barriers panels: - BLUE - room: Directional Gallery @@ -2924,9 +2924,9 @@ - Color Arrow Room Doors/Door_purple_hider_1 - Color Arrow Room Doors/Door_purple_hider_2 - Color Arrow Room Doors/Door_purple_hider_3 - location_name: Color Hunt - RED and BLUE - item_name: Champion's Rest - Purple Barrier - group: Champion's Rest - Color Barriers + location_name: Color Barriers - RED and BLUE + item_name: Color Hunt - Purple Barrier + group: Color Hunt Barriers panels: - BLUE - room: Orange Tower Third Floor @@ -2936,7 +2936,7 @@ - Color Arrow Room Doors/Door_all_hider_1 - Color Arrow Room Doors/Door_all_hider_2 - Color Arrow Room Doors/Door_all_hider_3 - location_name: Color Hunt - GREEN, ORANGE and PURPLE + location_name: Color Barriers - GREEN, ORANGE and PURPLE item_name: Champion's Rest - Entrance panels: - ORANGE @@ -3176,8 +3176,8 @@ Outside The Bold: entrances: Color Hallways: True - Champion's Rest: - room: Champion's Rest + Color Hunt: + room: Color Hunt door: Shortcut to The Steady The Bearer: room: The Bearer @@ -4002,7 +4002,7 @@ group: Color Hunt Barriers skip_location: True panels: - - room: Champion's Rest + - room: Color Hunt panel: YELLOW paintings: - id: smile_painting_7 @@ -4020,12 +4020,15 @@ orientation: south - id: cherry_painting orientation: east - Champion's Rest: + Color Hunt: entrances: Outside The Bold: door: Shortcut to The Steady Orange Tower Fourth Floor: True # sunwarp Roof: True # through ceiling of sunwarp + Champion's Rest: + room: Outside The Initiated + door: Entrance panels: EXIT: id: Rock Room/Panel_red_red @@ -4066,11 +4069,28 @@ required_door: room: Orange Tower Third Floor door: Orange Barrier - YOU: - id: Color Arrow Room/Panel_you + doors: + Shortcut to The Steady: + id: Rock Room Doors/Door_hint + panels: + - EXIT + paintings: + - id: arrows_painting_7 + orientation: east + - id: fruitbowl_painting3 + orientation: west + enter_only: True required_door: room: Outside The Initiated door: Entrance + Champion's Rest: + entrances: + Color Hunt: + room: Outside The Initiated + door: Entrance + panels: + YOU: + id: Color Arrow Room/Panel_you check: True colors: gray tag: forbid @@ -4078,49 +4098,20 @@ id: Color Arrow Room/Panel_me colors: gray tag: forbid - required_door: - room: Outside The Initiated - door: Entrance SECRET BLUE: # Pretend this and the other two are white, because they are snipes. # TODO: Extract them and randomize them? id: Color Arrow Room/Panel_secret_blue tag: forbid - required_door: - room: Outside The Initiated - door: Entrance SECRET YELLOW: id: Color Arrow Room/Panel_secret_yellow tag: forbid - required_door: - room: Outside The Initiated - door: Entrance SECRET RED: id: Color Arrow Room/Panel_secret_red tag: forbid - required_door: - room: Outside The Initiated - door: Entrance - doors: - Shortcut to The Steady: - id: Rock Room Doors/Door_hint - panels: - - EXIT paintings: - - id: arrows_painting_7 - orientation: east - - id: fruitbowl_painting3 - orientation: west - enter_only: True - required_door: - room: Outside The Initiated - door: Entrance - id: colors_painting orientation: south - enter_only: True - required_door: - room: Outside The Initiated - door: Entrance The Bearer: entrances: Outside The Bold: diff --git a/worlds/lingo/data/ids.yaml b/worlds/lingo/data/ids.yaml index 2b9e7f3d8ca..56c22ad1bec 100644 --- a/worlds/lingo/data/ids.yaml +++ b/worlds/lingo/data/ids.yaml @@ -489,7 +489,7 @@ panels: WINDWARD: 444803 LIGHT: 444804 REWIND: 444805 - Champion's Rest: + Color Hunt: EXIT: 444806 HUES: 444807 RED: 444808 @@ -498,6 +498,7 @@ panels: GREEN: 444811 PURPLE: 444812 ORANGE: 444813 + Champion's Rest: YOU: 444814 ME: 444815 SECRET BLUE: 444816 @@ -1286,7 +1287,7 @@ doors: location: 445246 Yellow Barrier: item: 444538 - Champion's Rest: + Color Hunt: Shortcut to The Steady: item: 444539 location: 444806 @@ -1442,7 +1443,6 @@ door_groups: Fearless Doors: 444469 Backside Doors: 444473 Orange Tower First Floor - Shortcuts: 444484 - Champion's Rest - Color Barriers: 444489 Welcome Back Doors: 444492 Colorful Doors: 444498 Directional Gallery Doors: 444531 diff --git a/worlds/lingo/player_logic.py b/worlds/lingo/player_logic.py index f3efc2914c3..d87aa5672c7 100644 --- a/worlds/lingo/player_logic.py +++ b/worlds/lingo/player_logic.py @@ -196,9 +196,8 @@ def __init__(self, world: "LingoWorld"): ["Orange Tower Fourth Floor", "Hot Crusts Door"], ["Outside The Initiated", "Shortcut to Hub Room"], ["Orange Tower First Floor", "Shortcut to Hub Room"], ["Directional Gallery", "Shortcut to The Undeterred"], ["Orange Tower First Floor", "Salt Pepper Door"], ["Hub Room", "Crossroads Entrance"], - ["Champion's Rest", "Shortcut to The Steady"], ["The Bearer", "Shortcut to The Bold"], - ["Art Gallery", "Exit"], ["The Tenacious", "Shortcut to Hub Room"], - ["Outside The Agreeable", "Tenacious Entrance"] + ["Color Hunt", "Shortcut to The Steady"], ["The Bearer", "Shortcut to The Bold"], ["Art Gallery", "Exit"], + ["The Tenacious", "Shortcut to Hub Room"], ["Outside The Agreeable", "Tenacious Entrance"] ] pilgrimage_reqs = AccessRequirements() for door in fake_pilgrimage: