From d4c00ed26743b56823c4bd191502b6c16bf9e58a Mon Sep 17 00:00:00 2001 From: Fabian Dill Date: Sat, 29 Jun 2024 03:00:32 +0200 Subject: [PATCH 01/14] CommonClient: fix /received with items from Server (#3597) --- CommonClient.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/CommonClient.py b/CommonClient.py index 19dd44f592a4..f8d1fcb7a221 100644 --- a/CommonClient.py +++ b/CommonClient.py @@ -23,7 +23,7 @@ from MultiServer import CommandProcessor from NetUtils import (Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission, NetworkSlot, - RawJSONtoTextParser, add_json_text, add_json_location, add_json_item, JSONTypes) + RawJSONtoTextParser, add_json_text, add_json_location, add_json_item, JSONTypes, SlotType) from Utils import Version, stream_input, async_start from worlds import network_data_package, AutoWorldRegister import os @@ -862,7 +862,8 @@ async def process_server_cmd(ctx: CommonContext, args: dict): ctx.team = args["team"] ctx.slot = args["slot"] # int keys get lost in JSON transfer - ctx.slot_info = {int(pid): data for pid, data in args["slot_info"].items()} + ctx.slot_info = {0: NetworkSlot("Archipelago", "Archipelago", SlotType.player)} + ctx.slot_info.update({int(pid): data for pid, data in args["slot_info"].items()}) ctx.hint_points = args.get("hint_points", 0) ctx.consume_players_package(args["players"]) ctx.stored_data_notification_keys.add(f"_read_hints_{ctx.team}_{ctx.slot}") From 1c817e1eb7ae2d9b2a592cfc6e5b1a9f407aabb8 Mon Sep 17 00:00:00 2001 From: Justus Lind Date: Sun, 30 Jun 2024 09:13:00 +1000 Subject: [PATCH 02/14] Muse Dash: Update installation guides to recommend installing v0.6.1. (#3594) * Update installation guides to recommend installing v0.6.1. * Fix spanish spacing. * Apply spanish changes. --- worlds/musedash/docs/setup_en.md | 7 ++++--- worlds/musedash/docs/setup_es.md | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/worlds/musedash/docs/setup_en.md b/worlds/musedash/docs/setup_en.md index 312cdbd1958f..a3c2f43a91f0 100644 --- a/worlds/musedash/docs/setup_en.md +++ b/worlds/musedash/docs/setup_en.md @@ -17,11 +17,12 @@ ## Installing the Archipelago mod to Muse Dash 1. Download [MelonLoader.Installer.exe](https://github.com/LavaGang/MelonLoader/releases/latest) and run it. -2. Choose the automated tab, click the select button and browse to `MuseDash.exe`. Then click install. +2. Choose the automated tab, click the select button and browse to `MuseDash.exe`. - You can find the folder in steam by finding the game in your library, right clicking it and choosing *Manage→Browse Local Files*. - If you click the bar at the top telling you your current folder, this will give you a path you can copy. If you paste that into the window popped up by **MelonLoader**, it will automatically go to the same folder. -3. Run the game once, and wait until you get to the Muse Dash start screen before exiting. -4. Download the latest [Muse Dash Archipelago Mod](https://github.com/DeamonHunter/ArchipelagoMuseDash/releases/latest) and then extract that into the newly created `/Mods/` folder in MuseDash's install location. +3. Uncheck "Latest" and select v0.6.1. Then click install. +4. Run the game once, and wait until you get to the Muse Dash start screen before exiting. +5. Download the latest [Muse Dash Archipelago Mod](https://github.com/DeamonHunter/ArchipelagoMuseDash/releases/latest) and then extract that into the newly created `/Mods/` folder in MuseDash's install location. - All files must be under the `/Mods/` folder and not within a sub folder inside of `/Mods/` If you've successfully installed everything, a button will appear in the bottom right which will allow you to log into an Archipelago server. diff --git a/worlds/musedash/docs/setup_es.md b/worlds/musedash/docs/setup_es.md index 1b16c7af3f54..fe5358921def 100644 --- a/worlds/musedash/docs/setup_es.md +++ b/worlds/musedash/docs/setup_es.md @@ -17,11 +17,12 @@ ## Instalar el mod de Archipelago en Muse Dash 1. Descarga [MelonLoader.Installer.exe](https://github.com/LavaGang/MelonLoader/releases/latest) y ejecutalo. -2. Elije la pestaña "automated", haz clic en el botón "select" y busca tu `MuseDash.exe`. Luego haz clic en "install". +2. Elije la pestaña "automated", haz clic en el botón "select" y busca tu `MuseDash.exe`. - Puedes encontrar la carpeta en Steam buscando el juego en tu biblioteca, haciendo clic derecho sobre el y elegir *Administrar→Ver archivos locales*. - Si haces clic en la barra superior que te indica la carpeta en la que estas, te dará la dirección de ésta para que puedas copiarla. Al pegar esa dirección en la ventana que **MelonLoader** abre, irá automaticamente a esa carpeta. -3. Ejecuta el juego una vez, y espera hasta que aparezca la pantalla de inicio de Muse Dash antes de cerrarlo. -4. Descarga la última version de [Muse Dash Archipelago Mod](https://github.com/DeamonHunter/ArchipelagoMuseDash/releases/latest) y extraelo en la nueva carpeta creada llamada `/Mods/`, localizada en la carpeta de instalación de Muse Dash. +3. Desmarca "Latest" y selecciona v0.6.1. Luego haz clic en "install". +4. Ejecuta el juego una vez, y espera hasta que aparezca la pantalla de inicio de Muse Dash antes de cerrarlo. +5. Descarga la última version de [Muse Dash Archipelago Mod](https://github.com/DeamonHunter/ArchipelagoMuseDash/releases/latest) y extraelo en la nueva carpeta creada llamada `/Mods/`, localizada en la carpeta de instalación de Muse Dash. - Todos los archivos deben ir directamente en la carpeta `/Mods/`, y NO en una subcarpeta dentro de la carpeta `/Mods/` Si todo fue instalado correctamente, un botón aparecerá en la parte inferior derecha del juego una vez abierto, que te permitirá conectarte al servidor de Archipelago. From 6191ff4b47eaccbcce3d1ed44d5cc340d6b33bfa Mon Sep 17 00:00:00 2001 From: Mrks <68022469+mrkssr@users.noreply.github.com> Date: Sun, 30 Jun 2024 01:14:39 +0200 Subject: [PATCH 03/14] LADX: Fixed Display Names In Options Page (#3584) * Fixed option group display names. * Fixed display names for -at the moment- unused options. --- worlds/ladx/Options.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/worlds/ladx/Options.py b/worlds/ladx/Options.py index be90ee597469..c5dcc080537c 100644 --- a/worlds/ladx/Options.py +++ b/worlds/ladx/Options.py @@ -52,6 +52,7 @@ class TextShuffle(DefaultOffToggle): [On] Shuffles all the text in the game [Off] (default) doesn't shuffle them. """ + display_name = "Text Shuffle" class Rooster(DefaultOnToggle, LADXROption): @@ -68,7 +69,8 @@ class Boomerang(Choice): [Normal] requires Magnifying Lens to get the boomerang. [Gift] The boomerang salesman will give you a random item, and the boomerang is shuffled. """ - + display_name = "Boomerang" + normal = 0 gift = 1 default = gift @@ -113,6 +115,7 @@ class APTitleScreen(DefaultOnToggle): class BossShuffle(Choice): + display_name = "Boss Shuffle" none = 0 shuffle = 1 random = 2 @@ -120,6 +123,7 @@ class BossShuffle(Choice): class DungeonItemShuffle(Choice): + display_name = "Dungeon Item Shuffle" option_original_dungeon = 0 option_own_dungeons = 1 option_own_world = 2 @@ -291,6 +295,7 @@ class Bowwow(Choice): [Normal] BowWow is in the item pool, but can be logically expected as a damage source. [Swordless] The progressive swords are removed from the item pool. """ + display_name = "BowWow" normal = 0 swordless = 1 default = normal @@ -466,6 +471,7 @@ class Music(Choice, LADXROption): [Shuffled] Shuffled Music [Off] No music """ + display_name = "Music" ladxr_name = "music" option_vanilla = 0 option_shuffled = 1 @@ -485,6 +491,7 @@ 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. [Off] No change """ + display_name = "Warp Improvements" class AdditionalWarpPoints(DefaultOffToggle): @@ -492,6 +499,7 @@ class AdditionalWarpPoints(DefaultOffToggle): [On] (requires warp improvements) Adds a warp point at Crazy Tracy's house (the Mambo teleport spot) and Eagle's Tower [Off] No change """ + display_name = "Additional Warp Points" ladx_option_groups = [ OptionGroup("Goal Options", [ From 2424fb0c5bfc4962cdec028431a8bddf7f5777fa Mon Sep 17 00:00:00 2001 From: Justus Lind Date: Sun, 30 Jun 2024 09:15:13 +1000 Subject: [PATCH 04/14] Muse Dash: 6th Anniversary Song update (#3593) * 6th Anniversary Update songs. * Forgot to fix the name of Heartbeat. --- worlds/musedash/MuseDashData.txt | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/worlds/musedash/MuseDashData.txt b/worlds/musedash/MuseDashData.txt index 49c26e45799d..6f48d6af9fdd 100644 --- a/worlds/musedash/MuseDashData.txt +++ b/worlds/musedash/MuseDashData.txt @@ -557,3 +557,13 @@ Tsukuyomi Ni Naru|74-2|CHUNITHM COURSE MUSE|False|5|7|9| The wheel to the right|74-3|CHUNITHM COURSE MUSE|True|5|7|9|11 Climax|74-4|CHUNITHM COURSE MUSE|True|4|8|11|11 Spider's Thread|74-5|CHUNITHM COURSE MUSE|True|5|8|10|12 +HIT ME UP|43-54|MD Plus Project|True|4|6|8| +Test Me feat. Uyeon|43-55|MD Plus Project|True|3|5|9| +Assault TAXI|43-56|MD Plus Project|True|4|7|10| +No|43-57|MD Plus Project|False|4|6|9| +Pop it|43-58|MD Plus Project|True|1|3|6| +HEARTBEAT! KyunKyun!|43-59|MD Plus Project|True|4|6|9| +SUPERHERO|75-0|Novice Rider Pack|False|2|4|7| +Highway_Summer|75-1|Novice Rider Pack|True|2|4|6| +Mx. Black Box|75-2|Novice Rider Pack|True|5|7|9| +Sweet Encounter|75-3|Novice Rider Pack|True|2|4|7| From 55cb81d48721c9270a9e9790e109e53dfd858ccb Mon Sep 17 00:00:00 2001 From: Silent <110704408+silent-destroyer@users.noreply.github.com> Date: Sat, 29 Jun 2024 19:17:00 -0400 Subject: [PATCH 05/14] TUNIC: Update victory condition (#3579) * Add hero relics to victory condition * Update __init__.py * Remove unneeded local variables for options * Use has_group_unique * fix spacing --- worlds/tunic/__init__.py | 23 ++++++++++++++++------- worlds/tunic/er_rules.py | 3 ++- worlds/tunic/items.py | 13 ++++++------- worlds/tunic/rules.py | 2 +- 4 files changed, 25 insertions(+), 16 deletions(-) diff --git a/worlds/tunic/__init__.py b/worlds/tunic/__init__.py index 624208da3a0b..92834d96b07f 100644 --- a/worlds/tunic/__init__.py +++ b/worlds/tunic/__init__.py @@ -156,9 +156,6 @@ def create_item(self, name: str) -> TunicItem: return TunicItem(name, item_data.classification, self.item_name_to_id[name], self.player) def create_items(self) -> None: - keys_behind_bosses = self.options.keys_behind_bosses - hexagon_quest = self.options.hexagon_quest - sword_progression = self.options.sword_progression tunic_items: List[TunicItem] = [] self.slot_data_items = [] @@ -172,7 +169,7 @@ def create_items(self) -> None: if self.options.start_with_sword: self.multiworld.push_precollected(self.create_item("Sword")) - if sword_progression: + if self.options.sword_progression: items_to_create["Stick"] = 0 items_to_create["Sword"] = 0 else: @@ -189,9 +186,9 @@ def create_items(self) -> None: self.slot_data_items.append(laurels) items_to_create["Hero's Laurels"] = 0 - if keys_behind_bosses: + if self.options.keys_behind_bosses: for rgb_hexagon, location in hexagon_locations.items(): - hex_item = self.create_item(gold_hexagon if hexagon_quest else rgb_hexagon) + hex_item = self.create_item(gold_hexagon if self.options.hexagon_quest else rgb_hexagon) self.multiworld.get_location(location, self.player).place_locked_item(hex_item) self.slot_data_items.append(hex_item) items_to_create[rgb_hexagon] = 0 @@ -222,7 +219,7 @@ def remove_filler(amount: int) -> None: ladder_count += 1 remove_filler(ladder_count) - if hexagon_quest: + if self.options.hexagon_quest: # Calculate number of hexagons in item pool hexagon_goal = self.options.hexagon_goal extra_hexagons = self.options.extra_hexagon_percentage @@ -238,6 +235,18 @@ def remove_filler(amount: int) -> None: remove_filler(items_to_create[gold_hexagon]) + for hero_relic in item_name_groups["Hero Relics"]: + relic_item = TunicItem(hero_relic, ItemClassification.useful, self.item_name_to_id[hero_relic], self.player) + tunic_items.append(relic_item) + items_to_create[hero_relic] = 0 + + if not self.options.ability_shuffling: + for page in item_name_groups["Abilities"]: + if items_to_create[page] > 0: + page_item = TunicItem(page, ItemClassification.useful, self.item_name_to_id[page], self.player) + tunic_items.append(page_item) + items_to_create[page] = 0 + if self.options.maskless: mask_item = TunicItem("Scavenger Mask", ItemClassification.useful, self.item_name_to_id["Scavenger Mask"], self.player) tunic_items.append(mask_item) diff --git a/worlds/tunic/er_rules.py b/worlds/tunic/er_rules.py index 2706d9595d00..d9348628ce9c 100644 --- a/worlds/tunic/er_rules.py +++ b/worlds/tunic/er_rules.py @@ -991,7 +991,8 @@ def set_er_region_rules(world: "TunicWorld", ability_unlocks: Dict[str, int], re connecting_region=regions["Spirit Arena Victory"], rule=lambda state: (state.has(gold_hexagon, player, world.options.hexagon_goal.value) if world.options.hexagon_quest else - state.has_all({red_hexagon, green_hexagon, blue_hexagon, "Unseal the Heir"}, player))) + (state.has_all({red_hexagon, green_hexagon, blue_hexagon, "Unseal the Heir"}, player) + and state.has_group_unique("Hero Relics", player, 6)))) # connecting the regions portals are in to other portals you can access via ladder storage # using has_stick instead of can_ladder_storage since it's already checking the logic rules diff --git a/worlds/tunic/items.py b/worlds/tunic/items.py index f470ea540d76..a8aec9f74485 100644 --- a/worlds/tunic/items.py +++ b/worlds/tunic/items.py @@ -64,12 +64,12 @@ class TunicItemData(NamedTuple): "HP Offering": TunicItemData(ItemClassification.useful, 6, 48, "Offerings"), "MP Offering": TunicItemData(ItemClassification.useful, 3, 49, "Offerings"), "SP Offering": TunicItemData(ItemClassification.useful, 2, 50, "Offerings"), - "Hero Relic - ATT": TunicItemData(ItemClassification.useful, 1, 51, "Hero Relics"), - "Hero Relic - DEF": TunicItemData(ItemClassification.useful, 1, 52, "Hero Relics"), - "Hero Relic - HP": TunicItemData(ItemClassification.useful, 1, 53, "Hero Relics"), - "Hero Relic - MP": TunicItemData(ItemClassification.useful, 1, 54, "Hero Relics"), - "Hero Relic - POTION": TunicItemData(ItemClassification.useful, 1, 55, "Hero Relics"), - "Hero Relic - SP": TunicItemData(ItemClassification.useful, 1, 56, "Hero Relics"), + "Hero Relic - ATT": TunicItemData(ItemClassification.progression_skip_balancing, 1, 51, "Hero Relics"), + "Hero Relic - DEF": TunicItemData(ItemClassification.progression_skip_balancing, 1, 52, "Hero Relics"), + "Hero Relic - HP": TunicItemData(ItemClassification.progression_skip_balancing, 1, 53, "Hero Relics"), + "Hero Relic - MP": TunicItemData(ItemClassification.progression_skip_balancing, 1, 54, "Hero Relics"), + "Hero Relic - POTION": TunicItemData(ItemClassification.progression_skip_balancing, 1, 55, "Hero Relics"), + "Hero Relic - SP": TunicItemData(ItemClassification.progression_skip_balancing, 1, 56, "Hero Relics"), "Orange Peril Ring": TunicItemData(ItemClassification.useful, 1, 57, "Cards"), "Tincture": TunicItemData(ItemClassification.useful, 1, 58, "Cards"), "Scavenger Mask": TunicItemData(ItemClassification.progression, 1, 59, "Cards"), @@ -143,7 +143,6 @@ class TunicItemData(NamedTuple): "Pages 50-51": TunicItemData(ItemClassification.useful, 1, 127, "Pages"), "Pages 52-53 (Icebolt)": TunicItemData(ItemClassification.progression, 1, 128, "Pages"), "Pages 54-55": TunicItemData(ItemClassification.useful, 1, 129, "Pages"), - "Ladders near Weathervane": TunicItemData(ItemClassification.progression, 0, 130, "Ladders"), "Ladders near Overworld Checkpoint": TunicItemData(ItemClassification.progression, 0, 131, "Ladders"), "Ladders near Patrol Cave": TunicItemData(ItemClassification.progression, 0, 132, "Ladders"), diff --git a/worlds/tunic/rules.py b/worlds/tunic/rules.py index e6bc490192ae..97270b5a2a81 100644 --- a/worlds/tunic/rules.py +++ b/worlds/tunic/rules.py @@ -128,7 +128,7 @@ def set_region_rules(world: "TunicWorld", ability_unlocks: Dict[str, int]) -> No or has_ice_grapple_logic(False, state, player, options, ability_unlocks) multiworld.get_entrance("Overworld -> Spirit Arena", player).access_rule = \ lambda state: (state.has(gold_hexagon, player, options.hexagon_goal.value) if options.hexagon_quest.value - else state.has_all({red_hexagon, green_hexagon, blue_hexagon}, player)) and \ + else state.has_all({red_hexagon, green_hexagon, blue_hexagon}, player) and state.has_group_unique("Hero Relics", player, 6)) and \ has_ability(state, player, prayer, options, ability_unlocks) and has_sword(state, player) and \ state.has_any({lantern, laurels}, player) From 192f1b3fae415e820940f0c4f45d97d9c7d24252 Mon Sep 17 00:00:00 2001 From: Sunny Bat Date: Sat, 29 Jun 2024 16:18:09 -0700 Subject: [PATCH 06/14] Update Raft option text, setup guide text (#3272) * Update Raft option text, setup guide * Address comments * Address PR comments --------- Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com> --- worlds/raft/Options.py | 14 ++++++++++---- worlds/raft/docs/setup_en.md | 2 +- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/worlds/raft/Options.py b/worlds/raft/Options.py index b9d0a2298a07..696d4dbab477 100644 --- a/worlds/raft/Options.py +++ b/worlds/raft/Options.py @@ -32,7 +32,13 @@ class FillerItemTypes(Choice): option_both = 2 class IslandFrequencyLocations(Choice): - """Sets where frequencies for story islands are located.""" + """Sets where frequencies for story islands are located. + Vanilla will keep frequencies in their vanilla, non-randomized locations. + Random On Island will randomize each frequency within its vanilla island, but will preserve island order. + Random Island Order will change the order you visit islands, but will preserve the vanilla location of each frequency unlock. + Random On Island Random Order will randomize the location containing the frequency on each island and randomize the order. + Progressive will randomize the frequencies to anywhere, but will always unlock the frequencies in vanilla order as the frequency items are received. + Anywhere will randomize the frequencies to anywhere, and frequencies will be received in any order.""" display_name = "Frequency locations" option_vanilla = 0 option_random_on_island = 1 @@ -53,7 +59,8 @@ class IslandGenerationDistance(Choice): default = 8 class ExpensiveResearch(Toggle): - """Makes unlocking items in the Crafting Table consume the researched items.""" + """If No is selected, researching items and unlocking items in the Crafting Table works the same as vanilla Raft. + If Yes is selected, each unlock in the Crafting Table will require its own set of researched items in order to unlock it.""" display_name = "Expensive research" class ProgressiveItems(DefaultOnToggle): @@ -66,8 +73,7 @@ class BigIslandEarlyCrafting(Toggle): display_name = "Early recipes behind big islands" class PaddleboardMode(Toggle): - """Sets later story islands to in logic without an Engine or Steering Wheel. May require lots of paddling. Not - recommended.""" + """Sets later story islands to be in logic without an Engine or Steering Wheel. May require lots of paddling.""" display_name = "Paddleboard Mode" raft_options = { diff --git a/worlds/raft/docs/setup_en.md b/worlds/raft/docs/setup_en.md index 236bb8d8acf2..16e7883776c3 100644 --- a/worlds/raft/docs/setup_en.md +++ b/worlds/raft/docs/setup_en.md @@ -17,7 +17,7 @@ 4. Open RML and click Play. If you've already installed it, the executable that was used to install RML ("RMLLauncher.exe" unless renamed) should be used to run RML. Raft should start after clicking Play. -5. Open the RML menu. This should open automatically when Raft first loads. If it does not, and you see RML information in the top center of the Raft main menu, press F9 to open it. +5. Open the RML menu. This should open automatically when Raft first loads. If it does not, and you see RML information in the top center of the Raft main menu, press F9 to open it. If you do not see RML information at the top, close Raft+RML, go back to Step 4 and run RML as administrator. 6. Navigate to the "Mod manager" tab in the left-hand menu. From 31bd5e3ebcc2b135fa25672b2d3cd0658a3c0f25 Mon Sep 17 00:00:00 2001 From: Scipio Wright Date: Sat, 29 Jun 2024 19:19:36 -0400 Subject: [PATCH 07/14] OOT: Add keys item_name_group (#3218) * Add keys item_name_group * Pep8ify * Capitalizing Keys cause Bottles is capitalized, also putting it in the clearly marked hint groups area --- worlds/oot/__init__.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/worlds/oot/__init__.py b/worlds/oot/__init__.py index 270062f4bc64..89f10a5a2da0 100644 --- a/worlds/oot/__init__.py +++ b/worlds/oot/__init__.py @@ -173,6 +173,15 @@ class OOTWorld(World): "Adult Trade Item": {"Pocket Egg", "Pocket Cucco", "Cojiro", "Odd Mushroom", "Odd Potion", "Poachers Saw", "Broken Sword", "Prescription", "Eyeball Frog", "Eyedrops", "Claim Check"}, + "Keys": {"Small Key (Bottom of the Well)", "Small Key (Fire Temple)", "Small Key (Forest Temple)", + "Small Key (Ganons Castle)", "Small Key (Gerudo Training Ground)", "Small Key (Shadow Temple)", + "Small Key (Spirit Temple)", "Small Key (Thieves Hideout)", "Small Key (Water Temple)", + "Small Key Ring (Bottom of the Well)", "Small Key Ring (Fire Temple)", + "Small Key Ring (Forest Temple)", "Small Key Ring (Ganons Castle)", + "Small Key Ring (Gerudo Training Ground)", "Small Key Ring (Shadow Temple)", + "Small Key Ring (Spirit Temple)", "Small Key Ring (Thieves Hideout)", "Small Key Ring (Water Temple)", + "Boss Key (Fire Temple)", "Boss Key (Forest Temple)", "Boss Key (Ganons Castle)", + "Boss Key (Shadow Temple)", "Boss Key (Spirit Temple)", "Boss Key (Water Temple)"}, } location_name_groups = build_location_name_groups() From 52a13d38e9d3295d26e41895a922f6f91f4ae85f Mon Sep 17 00:00:00 2001 From: Silvris <58583688+Silvris@users.noreply.github.com> Date: Mon, 1 Jul 2024 13:47:40 -0500 Subject: [PATCH 08/14] Tests: fix error reporting in test_default_all_state_can_reach_everything (#3601) --- test/general/test_reachability.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/general/test_reachability.py b/test/general/test_reachability.py index e57c398b7beb..4b71762f77fe 100644 --- a/test/general/test_reachability.py +++ b/test/general/test_reachability.py @@ -41,15 +41,15 @@ def test_default_all_state_can_reach_everything(self): 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): + with self.subTest("Location should be reached", location=location.name): self.assertTrue(location.can_reach(state), f"{location.name} unreachable") for region in multiworld.get_regions(): if region.name in unreachable_regions: - with self.subTest("Region should be unreachable", region=region): + with self.subTest("Region should be unreachable", region=region.name): self.assertFalse(region.can_reach(state)) else: - with self.subTest("Region should be reached", region=region): + with self.subTest("Region should be reached", region=region.name): self.assertTrue(region.can_reach(state)) with self.subTest("Completion Condition"): From e95bb5ea56caae2ba8812c54f8d316eb1cc9a4e9 Mon Sep 17 00:00:00 2001 From: black-sliver <59490463+black-sliver@users.noreply.github.com> Date: Mon, 1 Jul 2024 21:47:49 +0200 Subject: [PATCH 09/14] WebHost: Better host room (#3496) * add Range= to log, making responses a lot smaller for massive rooms * switch xhr to fetch * post the form using fetch if possible * also refresh log faster while waiting for command echo / response * do not follow redirect, saving a request * do not post empty body * smooth-scroll the log view * paste the log into the div when loading the HTML (up to 1MB, rest will be `fetch`ed) * fix duplicate charset in display_log response --- WebHostLib/misc.py | 51 +++++++++++----- WebHostLib/templates/hostRoom.html | 93 +++++++++++++++++++++++++----- 2 files changed, 117 insertions(+), 27 deletions(-) diff --git a/WebHostLib/misc.py b/WebHostLib/misc.py index 5072f113bd54..4b7cb475fc8b 100644 --- a/WebHostLib/misc.py +++ b/WebHostLib/misc.py @@ -1,6 +1,6 @@ import datetime import os -from typing import List, Dict, Union +from typing import Dict, Iterator, List, Tuple, Union import jinja2.exceptions from flask import request, redirect, url_for, render_template, Response, session, abort, send_from_directory @@ -97,25 +97,36 @@ def new_room(seed: UUID): return redirect(url_for("host_room", room=room.id)) -def _read_log(path: str): - if os.path.exists(path): - with open(path, encoding="utf-8-sig") as log: - yield from log - else: - yield f"Logfile {path} does not exist. " \ - f"Likely a crash during spinup of multiworld instance or it is still spinning up." +def _read_log(path: str, offset: int = 0) -> Iterator[bytes]: + with open(path, "rb") as log: + marker = log.read(3) # skip optional BOM + if marker != b'\xEF\xBB\xBF': + log.seek(0, os.SEEK_SET) + log.seek(offset, os.SEEK_CUR) + yield from log @app.route('/log/') -def display_log(room: UUID): +def display_log(room: UUID) -> Union[str, Response, Tuple[str, int]]: room = Room.get(id=room) if room is None: return abort(404) if room.owner == session["_id"]: file_path = os.path.join("logs", str(room.id) + ".txt") - if os.path.exists(file_path): - return Response(_read_log(file_path), mimetype="text/plain;charset=UTF-8") - return "Log File does not exist." + try: + range_header = request.headers.get("Range") + if range_header: + range_type, range_values = range_header.split('=') + start, end = map(str.strip, range_values.split('-', 1)) + if range_type != "bytes" or end != "": + return "Unsupported range", 500 + # NOTE: we skip Content-Range in the response here, which isn't great but works for our JS + return Response(_read_log(file_path, int(start)), mimetype="text/plain", status=206) + return Response(_read_log(file_path), mimetype="text/plain") + except FileNotFoundError: + return Response(f"Logfile {file_path} does not exist. " + f"Likely a crash during spinup of multiworld instance or it is still spinning up.", + mimetype="text/plain") return "Access Denied", 403 @@ -139,7 +150,21 @@ def host_room(room: UUID): with db_session: room.last_activity = now # will trigger a spinup, if it's not already running - return render_template("hostRoom.html", room=room, should_refresh=should_refresh) + def get_log(max_size: int = 1024000) -> str: + try: + raw_size = 0 + fragments: List[str] = [] + for block in _read_log(os.path.join("logs", str(room.id) + ".txt")): + if raw_size + len(block) > max_size: + fragments.append("…") + break + raw_size += len(block) + fragments.append(block.decode("utf-8")) + return "".join(fragments) + except FileNotFoundError: + return "" + + return render_template("hostRoom.html", room=room, should_refresh=should_refresh, get_log=get_log) @app.route('/favicon.ico') diff --git a/WebHostLib/templates/hostRoom.html b/WebHostLib/templates/hostRoom.html index 2bbfe4ad0169..fa8e26c2cbf8 100644 --- a/WebHostLib/templates/hostRoom.html +++ b/WebHostLib/templates/hostRoom.html @@ -44,7 +44,7 @@ {{ macros.list_patches_room(room) }} {% if room.owner == session["_id"] %}
-
+
-
- {% endif %}
From 401606e8e3014a2222d573ca54fc28cff7a87c65 Mon Sep 17 00:00:00 2001 From: Emily <35015090+EmilyV99@users.noreply.github.com> Date: Mon, 1 Jul 2024 17:34:06 -0400 Subject: [PATCH 10/14] Docs: Clarify docs for `create_items` stage (#3600) * Clarify docs re: `create_items` stage * adjust wording after feedback * adjust wording after more feedback --- docs/world api.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/world api.md b/docs/world api.md index 756ef3f31fdd..6551f2260416 100644 --- a/docs/world api.md +++ b/docs/world api.md @@ -456,8 +456,9 @@ In addition, the following methods can be implemented and are called in this ord 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 afterward. + called to place player's items into the MultiWorld's itempool. By the end of this step all regions, locations and + items have to be in the MultiWorld's regions and itempool. You cannot add or remove items, locations, or regions + after this step. Locations cannot be moved to different regions after this step. * `set_rules(self)` called to set access and item rules on locations and entrances. * `generate_basic(self)` From b6925c593e202cca7f627eb3592e06c149854858 Mon Sep 17 00:00:00 2001 From: black-sliver <59490463+black-sliver@users.noreply.github.com> Date: Tue, 2 Jul 2024 01:03:55 +0200 Subject: [PATCH 11/14] WebHost: Log: handle FileNotFoundError (#3603) --- WebHostLib/misc.py | 40 +++++++++++++++++++++------------------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/WebHostLib/misc.py b/WebHostLib/misc.py index 4b7cb475fc8b..01c1ad84a707 100644 --- a/WebHostLib/misc.py +++ b/WebHostLib/misc.py @@ -1,6 +1,6 @@ import datetime import os -from typing import Dict, Iterator, List, Tuple, Union +from typing import Any, IO, Dict, Iterator, List, Tuple, Union import jinja2.exceptions from flask import request, redirect, url_for, render_template, Response, session, abort, send_from_directory @@ -97,13 +97,13 @@ def new_room(seed: UUID): return redirect(url_for("host_room", room=room.id)) -def _read_log(path: str, offset: int = 0) -> Iterator[bytes]: - with open(path, "rb") as log: - marker = log.read(3) # skip optional BOM - if marker != b'\xEF\xBB\xBF': - log.seek(0, os.SEEK_SET) - log.seek(offset, os.SEEK_CUR) - yield from log +def _read_log(log: IO[Any], offset: int = 0) -> Iterator[bytes]: + marker = log.read(3) # skip optional BOM + if marker != b'\xEF\xBB\xBF': + log.seek(0, os.SEEK_SET) + log.seek(offset, os.SEEK_CUR) + yield from log + log.close() # free file handle as soon as possible @app.route('/log/') @@ -114,6 +114,7 @@ def display_log(room: UUID) -> Union[str, Response, Tuple[str, int]]: if room.owner == session["_id"]: file_path = os.path.join("logs", str(room.id) + ".txt") try: + log = open(file_path, "rb") range_header = request.headers.get("Range") if range_header: range_type, range_values = range_header.split('=') @@ -121,8 +122,8 @@ def display_log(room: UUID) -> Union[str, Response, Tuple[str, int]]: if range_type != "bytes" or end != "": return "Unsupported range", 500 # NOTE: we skip Content-Range in the response here, which isn't great but works for our JS - return Response(_read_log(file_path, int(start)), mimetype="text/plain", status=206) - return Response(_read_log(file_path), mimetype="text/plain") + return Response(_read_log(log, int(start)), mimetype="text/plain", status=206) + return Response(_read_log(log), mimetype="text/plain") except FileNotFoundError: return Response(f"Logfile {file_path} does not exist. " f"Likely a crash during spinup of multiworld instance or it is still spinning up.", @@ -152,15 +153,16 @@ def host_room(room: UUID): def get_log(max_size: int = 1024000) -> str: try: - raw_size = 0 - fragments: List[str] = [] - for block in _read_log(os.path.join("logs", str(room.id) + ".txt")): - if raw_size + len(block) > max_size: - fragments.append("…") - break - raw_size += len(block) - fragments.append(block.decode("utf-8")) - return "".join(fragments) + with open(os.path.join("logs", str(room.id) + ".txt"), "rb") as log: + raw_size = 0 + fragments: List[str] = [] + for block in _read_log(log): + if raw_size + len(block) > max_size: + fragments.append("…") + break + raw_size += len(block) + fragments.append(block.decode("utf-8")) + return "".join(fragments) except FileNotFoundError: return "" From 93617fa54609108fe115cf549a124ff444961548 Mon Sep 17 00:00:00 2001 From: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com> Date: Tue, 2 Jul 2024 23:59:26 +0200 Subject: [PATCH 12/14] The Witness: mypy compliance (#3112) * Make witness apworld mostly pass mypy * Fix all remaining mypy errors except the core ones * I'm a goofy stupid poopoo head * Two more fixes * ruff after merge * Mypy for new stuff * Oops * Stricter ruff rules (that I already comply with :3) * Deprecated ruff thing * wait no i lied * lol super nevermind * I can actually be slightly more specific * lint --- worlds/witness/__init__.py | 28 +-- worlds/witness/data/static_items.py | 12 +- worlds/witness/data/static_locations.py | 14 +- worlds/witness/data/static_logic.py | 68 +++---- worlds/witness/data/utils.py | 30 +-- worlds/witness/hints.py | 73 ++++--- worlds/witness/locations.py | 6 +- worlds/witness/player_items.py | 25 ++- worlds/witness/player_logic.py | 246 ++++++++++++------------ worlds/witness/regions.py | 40 ++-- worlds/witness/ruff.toml | 6 +- worlds/witness/rules.py | 20 +- 12 files changed, 299 insertions(+), 269 deletions(-) diff --git a/worlds/witness/__init__.py b/worlds/witness/__init__.py index ecab25db3d71..455c87d8e0d1 100644 --- a/worlds/witness/__init__.py +++ b/worlds/witness/__init__.py @@ -11,11 +11,12 @@ from worlds.AutoWorld import WebWorld, World from .data import static_items as static_witness_items +from .data import static_locations as static_witness_locations from .data import static_logic as static_witness_logic from .data.item_definition_classes import DoorItemDefinition, ItemData from .data.utils import get_audio_logs from .hints import CompactItemData, create_all_hints, make_compact_hint_data, make_laser_hints -from .locations import WitnessPlayerLocations, static_witness_locations +from .locations import WitnessPlayerLocations from .options import TheWitnessOptions, witness_option_groups from .player_items import WitnessItem, WitnessPlayerItems from .player_logic import WitnessPlayerLogic @@ -53,7 +54,8 @@ class WitnessWorld(World): options: TheWitnessOptions item_name_to_id = { - name: data.ap_code for name, data in static_witness_items.ITEM_DATA.items() + # ITEM_DATA doesn't have any event items in it + name: cast(int, data.ap_code) for name, data in static_witness_items.ITEM_DATA.items() } location_name_to_id = static_witness_locations.ALL_LOCATIONS_TO_ID item_name_groups = static_witness_items.ITEM_GROUPS @@ -142,7 +144,7 @@ def generate_early(self) -> None: ) self.player_regions: WitnessPlayerRegions = WitnessPlayerRegions(self.player_locations, self) - self.log_ids_to_hints = dict() + self.log_ids_to_hints = {} self.determine_sufficient_progression() @@ -279,7 +281,7 @@ def create_items(self) -> None: remaining_item_slots = pool_size - sum(item_pool.values()) # Add puzzle skips. - num_puzzle_skips = self.options.puzzle_skip_amount + num_puzzle_skips = self.options.puzzle_skip_amount.value if num_puzzle_skips > remaining_item_slots: warning(f"{self.multiworld.get_player_name(self.player)}'s Witness world has insufficient locations" @@ -301,21 +303,21 @@ def create_items(self) -> None: if self.player_items.item_data[item_name].local_only: self.options.local_items.value.add(item_name) - def fill_slot_data(self) -> dict: - self.log_ids_to_hints: Dict[int, CompactItemData] = dict() - self.laser_ids_to_hints: Dict[int, CompactItemData] = dict() + def fill_slot_data(self) -> Dict[str, Any]: + self.log_ids_to_hints: Dict[int, CompactItemData] = {} + self.laser_ids_to_hints: Dict[int, CompactItemData] = {} already_hinted_locations = set() # Laser hints if self.options.laser_hints: - laser_hints = make_laser_hints(self, static_witness_items.ITEM_GROUPS["Lasers"]) + laser_hints = make_laser_hints(self, sorted(static_witness_items.ITEM_GROUPS["Lasers"])) for item_name, hint in laser_hints.items(): item_def = cast(DoorItemDefinition, static_witness_logic.ALL_ITEMS[item_name]) self.laser_ids_to_hints[int(item_def.panel_id_hexes[0], 16)] = make_compact_hint_data(hint, self.player) - already_hinted_locations.add(hint.location) + already_hinted_locations.add(cast(Location, hint.location)) # Audio Log Hints @@ -378,13 +380,13 @@ class WitnessLocation(Location): game: str = "The Witness" entity_hex: int = -1 - def __init__(self, player: int, name: str, address: Optional[int], parent, ch_hex: int = -1) -> None: + def __init__(self, player: int, name: str, address: Optional[int], parent: Region, ch_hex: int = -1) -> None: super().__init__(player, name, address, parent) self.entity_hex = ch_hex def create_region(world: WitnessWorld, name: str, player_locations: WitnessPlayerLocations, - region_locations=None, exits=None) -> Region: + region_locations: Optional[List[str]] = None, exits: Optional[List[str]] = None) -> Region: """ Create an Archipelago Region for The Witness """ @@ -399,11 +401,11 @@ def create_region(world: WitnessWorld, name: str, player_locations: WitnessPlaye entity_hex = int( static_witness_logic.ENTITIES_BY_NAME[location]["entity_hex"], 0 ) - location = WitnessLocation( + location_obj = WitnessLocation( world.player, location, loc_id, ret, entity_hex ) - ret.locations.append(location) + ret.locations.append(location_obj) if exits: for single_exit in exits: ret.exits.append(Entrance(world.player, single_exit, ret)) diff --git a/worlds/witness/data/static_items.py b/worlds/witness/data/static_items.py index 8eb889f8203a..b0d8fc3c4f6e 100644 --- a/worlds/witness/data/static_items.py +++ b/worlds/witness/data/static_items.py @@ -1,4 +1,4 @@ -from typing import Dict, List +from typing import Dict, List, Set from BaseClasses import ItemClassification @@ -7,7 +7,7 @@ from .static_locations import ID_START ITEM_DATA: Dict[str, ItemData] = {} -ITEM_GROUPS: Dict[str, List[str]] = {} +ITEM_GROUPS: Dict[str, Set[str]] = {} # Useful items that are treated specially at generation time and should not be automatically added to the player's # item list during get_progression_items. @@ -22,13 +22,13 @@ def populate_items() -> None: if definition.category is ItemCategory.SYMBOL: classification = ItemClassification.progression - ITEM_GROUPS.setdefault("Symbols", []).append(item_name) + ITEM_GROUPS.setdefault("Symbols", set()).add(item_name) elif definition.category is ItemCategory.DOOR: classification = ItemClassification.progression - ITEM_GROUPS.setdefault("Doors", []).append(item_name) + ITEM_GROUPS.setdefault("Doors", set()).add(item_name) elif definition.category is ItemCategory.LASER: classification = ItemClassification.progression_skip_balancing - ITEM_GROUPS.setdefault("Lasers", []).append(item_name) + ITEM_GROUPS.setdefault("Lasers", set()).add(item_name) elif definition.category is ItemCategory.USEFUL: classification = ItemClassification.useful elif definition.category is ItemCategory.FILLER: @@ -47,7 +47,7 @@ def populate_items() -> None: def get_item_to_door_mappings() -> Dict[int, List[int]]: output: Dict[int, List[int]] = {} for item_name, item_data in ITEM_DATA.items(): - if not isinstance(item_data.definition, DoorItemDefinition): + if not isinstance(item_data.definition, DoorItemDefinition) or item_data.ap_code is None: continue output[item_data.ap_code] = [int(hex_string, 16) for hex_string in item_data.definition.panel_id_hexes] return output diff --git a/worlds/witness/data/static_locations.py b/worlds/witness/data/static_locations.py index e11544235ffc..de321d20c0f9 100644 --- a/worlds/witness/data/static_locations.py +++ b/worlds/witness/data/static_locations.py @@ -1,3 +1,5 @@ +from typing import Dict, Set, cast + from . import static_logic as static_witness_logic ID_START = 158000 @@ -441,17 +443,17 @@ "Town Obelisk Side 6", } -ALL_LOCATIONS_TO_ID = dict() +ALL_LOCATIONS_TO_ID: Dict[str, int] = {} -AREA_LOCATION_GROUPS = dict() +AREA_LOCATION_GROUPS: Dict[str, Set[str]] = {} -def get_id(entity_hex: str) -> str: +def get_id(entity_hex: str) -> int: """ Calculates the location ID for any given location """ - return static_witness_logic.ENTITIES_BY_HEX[entity_hex]["id"] + return cast(int, static_witness_logic.ENTITIES_BY_HEX[entity_hex]["id"]) def get_event_name(entity_hex: str) -> str: @@ -461,7 +463,7 @@ def get_event_name(entity_hex: str) -> str: action = " Opened" if static_witness_logic.ENTITIES_BY_HEX[entity_hex]["entityType"] == "Door" else " Solved" - return static_witness_logic.ENTITIES_BY_HEX[entity_hex]["checkName"] + action + return cast(str, static_witness_logic.ENTITIES_BY_HEX[entity_hex]["checkName"]) + action ALL_LOCATIONS_TO_IDS = { @@ -479,4 +481,4 @@ def get_event_name(entity_hex: str) -> str: for loc in ALL_LOCATIONS_TO_IDS: area = static_witness_logic.ENTITIES_BY_NAME[loc]["area"]["name"] - AREA_LOCATION_GROUPS.setdefault(area, []).append(loc) + AREA_LOCATION_GROUPS.setdefault(area, set()).add(loc) diff --git a/worlds/witness/data/static_logic.py b/worlds/witness/data/static_logic.py index ecd95ea6c0fa..a9175c0c30b3 100644 --- a/worlds/witness/data/static_logic.py +++ b/worlds/witness/data/static_logic.py @@ -1,5 +1,5 @@ from collections import defaultdict -from typing import Dict, List, Set, Tuple +from typing import Any, Dict, List, Optional, Set, Tuple from Utils import cache_argsless @@ -24,13 +24,37 @@ class StaticWitnessLogicObj: - def read_logic_file(self, lines) -> None: + def __init__(self, lines: Optional[List[str]] = None) -> None: + if lines is None: + lines = get_sigma_normal_logic() + + # All regions with a list of panels in them and the connections to other regions, before logic adjustments + self.ALL_REGIONS_BY_NAME: Dict[str, Dict[str, Any]] = {} + self.ALL_AREAS_BY_NAME: Dict[str, Dict[str, Any]] = {} + self.CONNECTIONS_WITH_DUPLICATES: Dict[str, Dict[str, Set[WitnessRule]]] = defaultdict(lambda: defaultdict(set)) + self.STATIC_CONNECTIONS_BY_REGION_NAME: Dict[str, Set[Tuple[str, WitnessRule]]] = {} + + self.ENTITIES_BY_HEX: Dict[str, Dict[str, Any]] = {} + self.ENTITIES_BY_NAME: Dict[str, Dict[str, Any]] = {} + self.STATIC_DEPENDENT_REQUIREMENTS_BY_HEX: Dict[str, Dict[str, WitnessRule]] = {} + + self.OBELISK_SIDE_ID_TO_EP_HEXES: Dict[int, Set[int]] = {} + + self.EP_TO_OBELISK_SIDE: Dict[str, str] = {} + + self.ENTITY_ID_TO_NAME: Dict[str, str] = {} + + self.read_logic_file(lines) + self.reverse_connections() + self.combine_connections() + + def read_logic_file(self, lines: List[str]) -> None: """ Reads the logic file and does the initial population of data structures """ - current_region = dict() - current_area = { + current_region = {} + current_area: Dict[str, Any] = { "name": "Misc", "regions": [], } @@ -155,7 +179,7 @@ def read_logic_file(self, lines) -> None: current_region["entities"].append(entity_hex) current_region["physical_entities"].append(entity_hex) - def reverse_connection(self, source_region: str, connection: Tuple[str, Set[WitnessRule]]): + def reverse_connection(self, source_region: str, connection: Tuple[str, Set[WitnessRule]]) -> None: target = connection[0] traversal_options = connection[1] @@ -169,13 +193,13 @@ def reverse_connection(self, source_region: str, connection: Tuple[str, Set[Witn if remaining_options: self.CONNECTIONS_WITH_DUPLICATES[target][source_region].add(frozenset(remaining_options)) - def reverse_connections(self): + def reverse_connections(self) -> None: # Iterate all connections for region_name, connections in list(self.CONNECTIONS_WITH_DUPLICATES.items()): for connection in connections.items(): self.reverse_connection(region_name, connection) - def combine_connections(self): + def combine_connections(self) -> None: # All regions need to be present, and this dict is copied later - Thus, defaultdict is not the correct choice. self.STATIC_CONNECTIONS_BY_REGION_NAME = {region_name: set() for region_name in self.ALL_REGIONS_BY_NAME} @@ -184,30 +208,6 @@ def combine_connections(self): combined_req = logical_or_witness_rules(requirement) self.STATIC_CONNECTIONS_BY_REGION_NAME[source].add((target, combined_req)) - def __init__(self, lines=None) -> None: - if lines is None: - lines = get_sigma_normal_logic() - - # All regions with a list of panels in them and the connections to other regions, before logic adjustments - self.ALL_REGIONS_BY_NAME = dict() - self.ALL_AREAS_BY_NAME = dict() - self.CONNECTIONS_WITH_DUPLICATES = defaultdict(lambda: defaultdict(lambda: set())) - self.STATIC_CONNECTIONS_BY_REGION_NAME = dict() - - self.ENTITIES_BY_HEX = dict() - self.ENTITIES_BY_NAME = dict() - self.STATIC_DEPENDENT_REQUIREMENTS_BY_HEX = dict() - - self.OBELISK_SIDE_ID_TO_EP_HEXES = dict() - - self.EP_TO_OBELISK_SIDE = dict() - - self.ENTITY_ID_TO_NAME = dict() - - self.read_logic_file(lines) - self.reverse_connections() - self.combine_connections() - # Item data parsed from WitnessItems.txt ALL_ITEMS: Dict[str, ItemDefinition] = {} @@ -276,12 +276,12 @@ def get_sigma_expert() -> StaticWitnessLogicObj: return StaticWitnessLogicObj(get_sigma_expert_logic()) -def __getattr__(name): +def __getattr__(name: str) -> StaticWitnessLogicObj: if name == "vanilla": return get_vanilla() - elif name == "sigma_normal": + if name == "sigma_normal": return get_sigma_normal() - elif name == "sigma_expert": + if name == "sigma_expert": return get_sigma_expert() raise AttributeError(f"module '{__name__}' has no attribute '{name}'") diff --git a/worlds/witness/data/utils.py b/worlds/witness/data/utils.py index 2934308df3ec..f89aaf7d3e18 100644 --- a/worlds/witness/data/utils.py +++ b/worlds/witness/data/utils.py @@ -1,7 +1,9 @@ from math import floor from pkgutil import get_data -from random import random -from typing import Any, Collection, Dict, FrozenSet, Iterable, List, Set, Tuple +from random import Random +from typing import Any, Collection, Dict, FrozenSet, Iterable, List, Set, Tuple, TypeVar + +T = TypeVar("T") # A WitnessRule is just an or-chain of and-conditions. # It represents the set of all options that could fulfill this requirement. @@ -11,9 +13,9 @@ WitnessRule = FrozenSet[FrozenSet[str]] -def weighted_sample(world_random: random, population: List, weights: List[float], k: int) -> List: +def weighted_sample(world_random: Random, population: List[T], weights: List[float], k: int) -> List[T]: positions = range(len(population)) - indices = [] + indices: List[int] = [] while True: needed = k - len(indices) if not needed: @@ -82,13 +84,13 @@ def define_new_region(region_string: str) -> Tuple[Dict[str, Any], Set[Tuple[str region_obj = { "name": region_name, "shortName": region_name_simple, - "entities": list(), - "physical_entities": list(), + "entities": [], + "physical_entities": [], } return region_obj, options -def parse_lambda(lambda_string) -> WitnessRule: +def parse_lambda(lambda_string: str) -> WitnessRule: """ Turns a lambda String literal like this: a | b & c into a set of sets like this: {{a}, {b, c}} @@ -97,18 +99,18 @@ def parse_lambda(lambda_string) -> WitnessRule: if lambda_string == "True": return frozenset([frozenset()]) split_ands = set(lambda_string.split(" | ")) - lambda_set = frozenset({frozenset(a.split(" & ")) for a in split_ands}) - - return lambda_set + return frozenset({frozenset(a.split(" & ")) for a in split_ands}) -_adjustment_file_cache = dict() +_adjustment_file_cache = {} def get_adjustment_file(adjustment_file: str) -> List[str]: if adjustment_file not in _adjustment_file_cache: - data = get_data(__name__, adjustment_file).decode("utf-8") - _adjustment_file_cache[adjustment_file] = [line.strip() for line in data.split("\n")] + data = get_data(__name__, adjustment_file) + if data is None: + raise FileNotFoundError(f"Could not find {adjustment_file}") + _adjustment_file_cache[adjustment_file] = [line.strip() for line in data.decode("utf-8").split("\n")] return _adjustment_file_cache[adjustment_file] @@ -237,7 +239,7 @@ def logical_and_witness_rules(witness_rules: Iterable[WitnessRule]) -> WitnessRu A logical formula might look like this: {{a, b}, {c, d}}, which would mean "a & b | c & d". These can be easily and-ed by just using the boolean distributive law: (a | b) & c = a & c | a & b. """ - current_overall_requirement = frozenset({frozenset()}) + current_overall_requirement: FrozenSet[FrozenSet[str]] = frozenset({frozenset()}) for next_dnf_requirement in witness_rules: new_requirement: Set[FrozenSet[str]] = set() diff --git a/worlds/witness/hints.py b/worlds/witness/hints.py index 535a36e13b6f..a1ca1b081d3c 100644 --- a/worlds/witness/hints.py +++ b/worlds/witness/hints.py @@ -1,11 +1,12 @@ import logging from dataclasses import dataclass -from typing import TYPE_CHECKING, Dict, List, Optional, Set, Tuple, Union +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Tuple, Union from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld from .data import static_logic as static_witness_logic from .data.utils import weighted_sample +from .player_items import WitnessItem if TYPE_CHECKING: from . import WitnessWorld @@ -22,7 +23,9 @@ class WitnessLocationHint: def __hash__(self) -> int: return hash(self.location) - def __eq__(self, other) -> bool: + def __eq__(self, other: Any) -> bool: + if not isinstance(other, WitnessLocationHint): + return False return self.location == other.location @@ -171,9 +174,13 @@ def word_direct_hint(world: "WitnessWorld", hint: WitnessLocationHint) -> Witnes location_name += " (" + world.multiworld.get_player_name(hint.location.player) + ")" item = hint.location.item - item_name = item.name - if item.player != world.player: - item_name += " (" + world.multiworld.get_player_name(item.player) + ")" + + item_name = "Nothing" + if item is not None: + item_name = item.name + + if item.player != world.player: + item_name += " (" + world.multiworld.get_player_name(item.player) + ")" if hint.hint_came_from_location: hint_text = f"{location_name} contains {item_name}." @@ -183,14 +190,17 @@ def word_direct_hint(world: "WitnessWorld", hint: WitnessLocationHint) -> Witnes return WitnessWordedHint(hint_text, hint.location) -def hint_from_item(world: "WitnessWorld", item_name: str, own_itempool: List[Item]) -> Optional[WitnessLocationHint]: - def get_real_location(multiworld: MultiWorld, location: Location): +def hint_from_item(world: "WitnessWorld", item_name: str, + own_itempool: List["WitnessItem"]) -> Optional[WitnessLocationHint]: + def get_real_location(multiworld: MultiWorld, location: Location) -> Location: """If this location is from an item_link pseudo-world, get the location that the item_link item is on. Return the original location otherwise / as a fallback.""" if location.player not in world.multiworld.groups: return location try: + if not location.item: + return location return multiworld.find_item(location.item.name, location.player) except StopIteration: return location @@ -209,17 +219,11 @@ def get_real_location(multiworld: MultiWorld, location: Location): def hint_from_location(world: "WitnessWorld", location: str) -> Optional[WitnessLocationHint]: - location_obj = world.get_location(location) - item_obj = location_obj.item - item_name = item_obj.name - if item_obj.player != world.player: - item_name += " (" + world.multiworld.get_player_name(item_obj.player) + ")" - - return WitnessLocationHint(location_obj, True) + return WitnessLocationHint(world.get_location(location), True) def get_items_and_locations_in_random_order(world: "WitnessWorld", - own_itempool: List[Item]) -> Tuple[List[str], List[str]]: + own_itempool: List["WitnessItem"]) -> Tuple[List[str], List[str]]: prog_items_in_this_world = sorted( item.name for item in own_itempool if item.advancement and item.code and item.location @@ -235,7 +239,7 @@ def get_items_and_locations_in_random_order(world: "WitnessWorld", return prog_items_in_this_world, locations_in_this_world -def make_always_and_priority_hints(world: "WitnessWorld", own_itempool: List[Item], +def make_always_and_priority_hints(world: "WitnessWorld", own_itempool: List["WitnessItem"], already_hinted_locations: Set[Location] ) -> Tuple[List[WitnessLocationHint], List[WitnessLocationHint]]: prog_items_in_this_world, loc_in_this_world = get_items_and_locations_in_random_order(world, own_itempool) @@ -282,14 +286,14 @@ def make_always_and_priority_hints(world: "WitnessWorld", own_itempool: List[Ite return always_hints, priority_hints -def make_extra_location_hints(world: "WitnessWorld", hint_amount: int, own_itempool: List[Item], +def make_extra_location_hints(world: "WitnessWorld", hint_amount: int, own_itempool: List["WitnessItem"], already_hinted_locations: Set[Location], hints_to_use_first: List[WitnessLocationHint], unhinted_locations_for_hinted_areas: Dict[str, Set[Location]]) -> List[WitnessWordedHint]: prog_items_in_this_world, locations_in_this_world = get_items_and_locations_in_random_order(world, own_itempool) next_random_hint_is_location = world.random.randrange(0, 2) - hints = [] + hints: List[WitnessWordedHint] = [] # This is a way to reverse a Dict[a,List[b]] to a Dict[b,a] area_reverse_lookup = { @@ -304,6 +308,7 @@ def make_extra_location_hints(world: "WitnessWorld", hint_amount: int, own_itemp logging.warning(f"Ran out of items/locations to hint for player {player_name}.") break + location_hint: Optional[WitnessLocationHint] if hints_to_use_first: location_hint = hints_to_use_first.pop() elif next_random_hint_is_location and locations_in_this_world: @@ -317,7 +322,7 @@ def make_extra_location_hints(world: "WitnessWorld", hint_amount: int, own_itemp next_random_hint_is_location = not next_random_hint_is_location continue - if not location_hint or location_hint.location in already_hinted_locations: + if location_hint is None or location_hint.location in already_hinted_locations: continue # Don't hint locations in areas that are almost fully hinted out already @@ -344,8 +349,8 @@ def choose_areas(world: "WitnessWorld", amount: int, locations_per_area: Dict[st When this happens, they are made less likely to receive an area hint. """ - unhinted_locations_per_area = dict() - unhinted_location_percentage_per_area = dict() + unhinted_locations_per_area = {} + unhinted_location_percentage_per_area = {} for area_name, locations in locations_per_area.items(): not_yet_hinted_locations = sum(location not in already_hinted_locations for location in locations) @@ -368,8 +373,8 @@ def choose_areas(world: "WitnessWorld", amount: int, locations_per_area: Dict[st def get_hintable_areas(world: "WitnessWorld") -> Tuple[Dict[str, List[Location]], Dict[str, List[Item]]]: potential_areas = list(static_witness_logic.ALL_AREAS_BY_NAME.keys()) - locations_per_area = dict() - items_per_area = dict() + locations_per_area = {} + items_per_area = {} for area in potential_areas: regions = [ @@ -533,7 +538,7 @@ def create_all_hints(world: "WitnessWorld", hint_amount: int, area_hints: int, location_hints_created_in_round_1 = len(generated_hints) - unhinted_locations_per_area: Dict[str, Set[Location]] = dict() + unhinted_locations_per_area: Dict[str, Set[Location]] = {} # Then, make area hints. if area_hints: @@ -584,17 +589,29 @@ def make_compact_hint_data(hint: WitnessWordedHint, local_player_number: int) -> location = hint.location area_amount = hint.area_amount - # None if junk hint, address if location hint, area string if area hint - arg_1 = location.address if location else (hint.area if hint.area else None) + # -1 if junk hint, address if location hint, area string if area hint + arg_1: Union[str, int] + if location and location.address is not None: + arg_1 = location.address + elif hint.area is not None: + arg_1 = hint.area + else: + arg_1 = -1 # self.player if junk hint, player if location hint, progression amount if area hint - arg_2 = area_amount if area_amount is not None else (location.player if location else local_player_number) + arg_2: int + if area_amount is not None: + arg_2 = area_amount + elif location is not None: + arg_2 = location.player + else: + arg_2 = local_player_number return hint.wording, arg_1, arg_2 def make_laser_hints(world: "WitnessWorld", laser_names: List[str]) -> Dict[str, WitnessWordedHint]: - laser_hints_by_name = dict() + laser_hints_by_name = {} for item_name in laser_names: location_hint = hint_from_item(world, item_name, world.own_itempool) diff --git a/worlds/witness/locations.py b/worlds/witness/locations.py index df8214ac9221..1796f051b896 100644 --- a/worlds/witness/locations.py +++ b/worlds/witness/locations.py @@ -61,9 +61,7 @@ def __init__(self, world: "WitnessWorld", player_logic: WitnessPlayerLogic) -> N sorted(self.CHECK_PANELHEX_TO_ID.items(), key=lambda item: item[1]) ) - event_locations = { - p for p in player_logic.USED_EVENT_NAMES_BY_HEX - } + event_locations = set(player_logic.USED_EVENT_NAMES_BY_HEX) self.EVENT_LOCATION_TABLE = { static_witness_locations.get_event_name(entity_hex): None @@ -80,5 +78,5 @@ def __init__(self, world: "WitnessWorld", player_logic: WitnessPlayerLogic) -> N def add_location_late(self, entity_name: str) -> None: entity_hex = static_witness_logic.ENTITIES_BY_NAME[entity_name]["entity_hex"] - self.CHECK_LOCATION_TABLE[entity_hex] = entity_name + self.CHECK_LOCATION_TABLE[entity_hex] = static_witness_locations.get_id(entity_hex) self.CHECK_PANELHEX_TO_ID[entity_hex] = static_witness_locations.get_id(entity_hex) diff --git a/worlds/witness/player_items.py b/worlds/witness/player_items.py index 627e5acccb90..718fd7d172ba 100644 --- a/worlds/witness/player_items.py +++ b/worlds/witness/player_items.py @@ -2,7 +2,7 @@ Defines progression, junk and event items for The Witness """ import copy -from typing import TYPE_CHECKING, Dict, List, Set +from typing import TYPE_CHECKING, Dict, List, Set, cast from BaseClasses import Item, ItemClassification, MultiWorld @@ -87,7 +87,8 @@ def __init__(self, world: "WitnessWorld", player_logic: WitnessPlayerLogic, if data.classification == ItemClassification.useful}.items(): if item_name in static_witness_items._special_usefuls: continue - elif item_name == "Energy Capacity": + + if item_name == "Energy Capacity": self._mandatory_items[item_name] = NUM_ENERGY_UPGRADES elif isinstance(item_data.classification, ProgressiveItemDefinition): self._mandatory_items[item_name] = len(item_data.mappings) @@ -184,15 +185,16 @@ def get_early_items(self) -> List[str]: output -= {item for item, weight in inner_item.items() if weight} # Sort the output for consistency across versions if the implementation changes but the logic does not. - return sorted(list(output)) + return sorted(output) def get_door_ids_in_pool(self) -> List[int]: """ Returns the total set of all door IDs that are controlled by items in the pool. """ output: List[int] = [] - for item_name, item_data in {name: data for name, data in self.item_data.items() - if isinstance(data.definition, DoorItemDefinition)}.items(): + for item_name, item_data in dict(self.item_data.items()).items(): + if not isinstance(item_data.definition, DoorItemDefinition): + continue output += [int(hex_string, 16) for hex_string in item_data.definition.panel_id_hexes] return output @@ -201,18 +203,21 @@ def get_symbol_ids_not_in_pool(self) -> List[int]: """ Returns the item IDs of symbol items that were defined in the configuration file but are not in the pool. """ - return [data.ap_code for name, data in static_witness_items.ITEM_DATA.items() - if name not in self.item_data.keys() and data.definition.category is ItemCategory.SYMBOL] + return [ + # data.ap_code is guaranteed for a symbol definition + cast(int, data.ap_code) for name, data in static_witness_items.ITEM_DATA.items() + if name not in self.item_data.keys() and data.definition.category is ItemCategory.SYMBOL + ] def get_progressive_item_ids_in_pool(self) -> Dict[int, List[int]]: output: Dict[int, List[int]] = {} - for item_name, quantity in {name: quantity for name, quantity in self._mandatory_items.items()}.items(): + for item_name, quantity in dict(self._mandatory_items.items()).items(): item = self.item_data[item_name] if isinstance(item.definition, ProgressiveItemDefinition): # Note: we need to reference the static table here rather than the player-specific one because the child # items were removed from the pool when we pruned out all progression items not in the settings. - output[item.ap_code] = [static_witness_items.ITEM_DATA[child_item].ap_code - for child_item in item.definition.child_item_names] + output[cast(int, item.ap_code)] = [cast(int, static_witness_items.ITEM_DATA[child_item].ap_code) + for child_item in item.definition.child_item_names] return output diff --git a/worlds/witness/player_logic.py b/worlds/witness/player_logic.py index 05b3cf3a98e4..b62c59b00ae1 100644 --- a/worlds/witness/player_logic.py +++ b/worlds/witness/player_logic.py @@ -22,6 +22,7 @@ from .data import static_logic as static_witness_logic from .data.item_definition_classes import DoorItemDefinition, ItemCategory, ProgressiveItemDefinition +from .data.static_logic import StaticWitnessLogicObj from .data.utils import ( WitnessRule, define_new_region, @@ -58,6 +59,95 @@ class WitnessPlayerLogic: """WITNESS LOGIC CLASS""" + VICTORY_LOCATION: str + + def __init__(self, world: "WitnessWorld", disabled_locations: Set[str], start_inv: Dict[str, int]) -> None: + self.YAML_DISABLED_LOCATIONS: Set[str] = disabled_locations + self.YAML_ADDED_ITEMS: Dict[str, int] = start_inv + + self.EVENT_PANELS_FROM_PANELS: Set[str] = set() + self.EVENT_PANELS_FROM_REGIONS: Set[str] = set() + + self.IRRELEVANT_BUT_NOT_DISABLED_ENTITIES: Set[str] = set() + + self.ENTITIES_WITHOUT_ENSURED_SOLVABILITY: Set[str] = set() + + self.UNREACHABLE_REGIONS: Set[str] = set() + + self.THEORETICAL_ITEMS: Set[str] = set() + self.THEORETICAL_ITEMS_NO_MULTI: Set[str] = set() + self.MULTI_AMOUNTS: Dict[str, int] = defaultdict(lambda: 1) + self.MULTI_LISTS: Dict[str, List[str]] = {} + self.PROG_ITEMS_ACTUALLY_IN_THE_GAME_NO_MULTI: Set[str] = set() + self.PROG_ITEMS_ACTUALLY_IN_THE_GAME: Set[str] = set() + self.DOOR_ITEMS_BY_ID: Dict[str, List[str]] = {} + self.STARTING_INVENTORY: Set[str] = set() + + self.DIFFICULTY = world.options.puzzle_randomization + + self.REFERENCE_LOGIC: StaticWitnessLogicObj + if self.DIFFICULTY == "sigma_expert": + self.REFERENCE_LOGIC = static_witness_logic.sigma_expert + elif self.DIFFICULTY == "none": + self.REFERENCE_LOGIC = static_witness_logic.vanilla + else: + self.REFERENCE_LOGIC = static_witness_logic.sigma_normal + + self.CONNECTIONS_BY_REGION_NAME_THEORETICAL: Dict[str, Set[Tuple[str, WitnessRule]]] = copy.deepcopy( + self.REFERENCE_LOGIC.STATIC_CONNECTIONS_BY_REGION_NAME + ) + self.CONNECTIONS_BY_REGION_NAME: Dict[str, Set[Tuple[str, WitnessRule]]] = copy.deepcopy( + self.REFERENCE_LOGIC.STATIC_CONNECTIONS_BY_REGION_NAME + ) + self.DEPENDENT_REQUIREMENTS_BY_HEX: Dict[str, Dict[str, WitnessRule]] = copy.deepcopy( + self.REFERENCE_LOGIC.STATIC_DEPENDENT_REQUIREMENTS_BY_HEX + ) + self.REQUIREMENTS_BY_HEX: Dict[str, WitnessRule] = {} + + self.EVENT_ITEM_PAIRS: Dict[str, str] = {} + self.COMPLETELY_DISABLED_ENTITIES: Set[str] = set() + self.DISABLE_EVERYTHING_BEHIND: Set[str] = set() + self.PRECOMPLETED_LOCATIONS: Set[str] = set() + self.EXCLUDED_LOCATIONS: Set[str] = set() + self.ADDED_CHECKS: Set[str] = set() + self.VICTORY_LOCATION = "0x0356B" + + self.ALWAYS_EVENT_NAMES_BY_HEX = { + "0x00509": "+1 Laser (Symmetry Laser)", + "0x012FB": "+1 Laser (Desert Laser)", + "0x09F98": "Desert Laser Redirection", + "0x01539": "+1 Laser (Quarry Laser)", + "0x181B3": "+1 Laser (Shadows Laser)", + "0x014BB": "+1 Laser (Keep Laser)", + "0x17C65": "+1 Laser (Monastery Laser)", + "0x032F9": "+1 Laser (Town Laser)", + "0x00274": "+1 Laser (Jungle Laser)", + "0x0C2B2": "+1 Laser (Bunker Laser)", + "0x00BF6": "+1 Laser (Swamp Laser)", + "0x028A4": "+1 Laser (Treehouse Laser)", + "0x17C34": "Mountain Entry", + "0xFFF00": "Bottom Floor Discard Turns On", + } + + self.USED_EVENT_NAMES_BY_HEX: Dict[str, str] = {} + self.CONDITIONAL_EVENTS: Dict[Tuple[str, str], str] = {} + + # The basic requirements to solve each entity come from StaticWitnessLogic. + # However, for any given world, the options (e.g. which item shuffles are enabled) affect the requirements. + self.make_options_adjustments(world) + self.determine_unrequired_entities(world) + self.find_unsolvable_entities(world) + + # After we have adjusted the raw requirements, we perform a dependency reduction for the entity requirements. + # This will make the access conditions way faster, instead of recursively checking dependent entities each time. + self.make_dependency_reduced_checklist() + + # Finalize which items actually exist in the MultiWorld and which get grouped into progressive items. + self.finalize_items() + + # Create event-item pairs for specific panels in the game. + self.make_event_panel_lists() + def reduce_req_within_region(self, entity_hex: str) -> WitnessRule: """ Panels in this game often only turn on when other panels are solved. @@ -77,9 +167,9 @@ def reduce_req_within_region(self, entity_hex: str) -> WitnessRule: # For the requirement of an entity, we consider two things: # 1. Any items this entity needs (e.g. Symbols or Door Items) - these_items = self.DEPENDENT_REQUIREMENTS_BY_HEX[entity_hex].get("items", frozenset({frozenset()})) + these_items: WitnessRule = self.DEPENDENT_REQUIREMENTS_BY_HEX[entity_hex].get("items", frozenset({frozenset()})) # 2. Any entities that this entity depends on (e.g. one panel powering on the next panel in a set) - these_panels = self.DEPENDENT_REQUIREMENTS_BY_HEX[entity_hex]["entities"] + these_panels: WitnessRule = self.DEPENDENT_REQUIREMENTS_BY_HEX[entity_hex]["entities"] # Remove any items that don't actually exist in the settings (e.g. Symbol Shuffle turned off) these_items = frozenset({ @@ -91,47 +181,49 @@ def reduce_req_within_region(self, entity_hex: str) -> WitnessRule: for subset in these_items: self.PROG_ITEMS_ACTUALLY_IN_THE_GAME_NO_MULTI.update(subset) - # If this entity is opened by a door item that exists in the itempool, add that item to its requirements. - # Also, remove any original power requirements this entity might have had. + # Handle door entities (door shuffle) if entity_hex in self.DOOR_ITEMS_BY_ID: + # If this entity is opened by a door item that exists in the itempool, add that item to its requirements. door_items = frozenset({frozenset([item]) for item in self.DOOR_ITEMS_BY_ID[entity_hex]}) for dependent_item in door_items: self.PROG_ITEMS_ACTUALLY_IN_THE_GAME_NO_MULTI.update(dependent_item) - all_options = logical_and_witness_rules([door_items, these_items]) + these_items = logical_and_witness_rules([door_items, these_items]) - # If this entity is not an EP, and it has an associated door item, ignore the original power dependencies - if static_witness_logic.ENTITIES_BY_HEX[entity_hex]["entityType"] != "EP": + # A door entity is opened by its door item instead of previous entities powering it. + # That means we need to ignore any dependent requirements. + # However, there are some entities that depend on other entities because of an environmental reason. + # Those requirements need to be preserved even in door shuffle. + entity_dependencies_need_to_be_preserved = ( + # EPs keep all their entity dependencies + static_witness_logic.ENTITIES_BY_HEX[entity_hex]["entityType"] != "EP" # 0x28A0D depends on another entity for *non-power* reasons -> This dependency needs to be preserved, # except in Expert, where that dependency doesn't exist, but now there *is* a power dependency. # In the future, it'd be wise to make a distinction between "power dependencies" and other dependencies. - if entity_hex == "0x28A0D" and not any("0x28998" in option for option in these_panels): - these_items = all_options - + or entity_hex == "0x28A0D" and not any("0x28998" in option for option in these_panels) # Another dependency that is not power-based: The Symmetry Island Upper Panel latches - elif entity_hex == "0x1C349": - these_items = all_options - - else: - return frozenset(all_options) + or entity_hex == "0x1C349" + ) - else: - these_items = all_options + # If this is not one of those special cases, solving this door entity only needs its own item requirement. + # Dependent entities from these_panels are ignored, and we just return these_items directly. + if not entity_dependencies_need_to_be_preserved: + return these_items # Now that we have item requirements and entity dependencies, it's time for the dependency reduction. # For each entity that this entity depends on (e.g. a panel turning on another panel), # Add that entities requirements to this entity. # If there are multiple options, consider each, and then or-chain them. - all_options = list() + all_options = [] for option in these_panels: - dependent_items_for_option = frozenset({frozenset()}) + dependent_items_for_option: WitnessRule = frozenset({frozenset()}) # For each entity in this option, resolve it to its actual requirement. for option_entity in option: - dep_obj = self.REFERENCE_LOGIC.ENTITIES_BY_HEX.get(option_entity) + dep_obj = self.REFERENCE_LOGIC.ENTITIES_BY_HEX.get(option_entity, {}) if option_entity in {"7 Lasers", "11 Lasers", "7 Lasers + Redirect", "11 Lasers + Redirect", "PP2 Weirdness", "Theater to Tunnels"}: @@ -525,13 +617,16 @@ def make_options_adjustments(self, world: "WitnessWorld") -> None: current_adjustment_type = line[:-1] continue + if current_adjustment_type is None: + raise ValueError(f"Adjustment lineset {adjustment_lineset} is malformed") + self.make_single_adjustment(current_adjustment_type, line) for entity_id in self.COMPLETELY_DISABLED_ENTITIES: if entity_id in self.DOOR_ITEMS_BY_ID: del self.DOOR_ITEMS_BY_ID[entity_id] - def discover_reachable_regions(self): + def discover_reachable_regions(self) -> Set[str]: """ Some options disable panels or remove specific items. This can make entire regions completely unreachable, because all their incoming connections are invalid. @@ -640,7 +735,7 @@ def reduce_connection_requirement(self, connection: Tuple[str, WitnessRule]) -> # Check each traversal option individually for option in connection[1]: - individual_entity_requirements = [] + individual_entity_requirements: List[WitnessRule] = [] for entity in option: # If a connection requires solving a disabled entity, it is not valid. if not self.solvability_guaranteed(entity) or entity in self.DISABLE_EVERYTHING_BEHIND: @@ -664,7 +759,7 @@ def reduce_connection_requirement(self, connection: Tuple[str, WitnessRule]) -> return logical_or_witness_rules(all_possibilities) - def make_dependency_reduced_checklist(self): + def make_dependency_reduced_checklist(self) -> None: """ Every entity has a requirement. This requirement may involve other entities. Example: Solving a panel powers a cable, and that cable turns on the next panel. @@ -679,12 +774,12 @@ def make_dependency_reduced_checklist(self): # Requirements are cached per entity. However, we might redo the whole reduction process multiple times. # So, we first clear this cache. - self.REQUIREMENTS_BY_HEX = dict() + self.REQUIREMENTS_BY_HEX = {} # We also clear any data structures that we might have filled in a previous dependency reduction - self.REQUIREMENTS_BY_HEX = dict() - self.USED_EVENT_NAMES_BY_HEX = dict() - self.CONNECTIONS_BY_REGION_NAME = dict() + self.REQUIREMENTS_BY_HEX = {} + self.USED_EVENT_NAMES_BY_HEX = {} + self.CONNECTIONS_BY_REGION_NAME = {} self.PROG_ITEMS_ACTUALLY_IN_THE_GAME_NO_MULTI = set() # Make independent requirements for entities @@ -695,22 +790,18 @@ def make_dependency_reduced_checklist(self): # Make independent region connection requirements based on the entities they require for region, connections in self.CONNECTIONS_BY_REGION_NAME_THEORETICAL.items(): - self.CONNECTIONS_BY_REGION_NAME[region] = [] - - new_connections = [] + new_connections = set() for connection in connections: overall_requirement = self.reduce_connection_requirement(connection) # If there is a way to use this connection, add it. if overall_requirement: - new_connections.append((connection[0], overall_requirement)) + new_connections.add((connection[0], overall_requirement)) - # If there are any usable outgoing connections from this region, add them. - if new_connections: - self.CONNECTIONS_BY_REGION_NAME[region] = new_connections + self.CONNECTIONS_BY_REGION_NAME[region] = new_connections - def finalize_items(self): + def finalize_items(self) -> None: """ Finalise which items are used in the world, and handle their progressive versions. """ @@ -808,8 +899,7 @@ def make_event_item_pair(self, entity_hex: str) -> Tuple[str, str]: if entity_hex not in self.USED_EVENT_NAMES_BY_HEX: warning(f'Entity "{name}" does not have an associated event name.') self.USED_EVENT_NAMES_BY_HEX[entity_hex] = name + " Event" - pair = (name, self.USED_EVENT_NAMES_BY_HEX[entity_hex]) - return pair + return (name, self.USED_EVENT_NAMES_BY_HEX[entity_hex]) def make_event_panel_lists(self) -> None: """ @@ -828,85 +918,3 @@ def make_event_panel_lists(self) -> None: for panel in self.USED_EVENT_NAMES_BY_HEX: pair = self.make_event_item_pair(panel) self.EVENT_ITEM_PAIRS[pair[0]] = pair[1] - - def __init__(self, world: "WitnessWorld", disabled_locations: Set[str], start_inv: Dict[str, int]) -> None: - self.YAML_DISABLED_LOCATIONS = disabled_locations - self.YAML_ADDED_ITEMS = start_inv - - self.EVENT_PANELS_FROM_PANELS = set() - self.EVENT_PANELS_FROM_REGIONS = set() - - self.IRRELEVANT_BUT_NOT_DISABLED_ENTITIES = set() - - self.ENTITIES_WITHOUT_ENSURED_SOLVABILITY = set() - - self.UNREACHABLE_REGIONS = set() - - self.THEORETICAL_ITEMS = set() - self.THEORETICAL_ITEMS_NO_MULTI = set() - self.MULTI_AMOUNTS = defaultdict(lambda: 1) - self.MULTI_LISTS = dict() - self.PROG_ITEMS_ACTUALLY_IN_THE_GAME_NO_MULTI = set() - self.PROG_ITEMS_ACTUALLY_IN_THE_GAME = set() - self.DOOR_ITEMS_BY_ID: Dict[str, List[str]] = {} - self.STARTING_INVENTORY = set() - - self.DIFFICULTY = world.options.puzzle_randomization - - if self.DIFFICULTY == "sigma_normal": - self.REFERENCE_LOGIC = static_witness_logic.sigma_normal - elif self.DIFFICULTY == "sigma_expert": - self.REFERENCE_LOGIC = static_witness_logic.sigma_expert - elif self.DIFFICULTY == "none": - self.REFERENCE_LOGIC = static_witness_logic.vanilla - - self.CONNECTIONS_BY_REGION_NAME_THEORETICAL = copy.deepcopy( - self.REFERENCE_LOGIC.STATIC_CONNECTIONS_BY_REGION_NAME - ) - self.CONNECTIONS_BY_REGION_NAME = dict() - self.DEPENDENT_REQUIREMENTS_BY_HEX = copy.deepcopy(self.REFERENCE_LOGIC.STATIC_DEPENDENT_REQUIREMENTS_BY_HEX) - self.REQUIREMENTS_BY_HEX = dict() - - self.EVENT_ITEM_PAIRS = dict() - self.COMPLETELY_DISABLED_ENTITIES = set() - self.DISABLE_EVERYTHING_BEHIND = set() - self.PRECOMPLETED_LOCATIONS = set() - self.EXCLUDED_LOCATIONS = set() - self.ADDED_CHECKS = set() - self.VICTORY_LOCATION: str - - self.ALWAYS_EVENT_NAMES_BY_HEX = { - "0x00509": "+1 Laser (Symmetry Laser)", - "0x012FB": "+1 Laser (Desert Laser)", - "0x09F98": "Desert Laser Redirection", - "0x01539": "+1 Laser (Quarry Laser)", - "0x181B3": "+1 Laser (Shadows Laser)", - "0x014BB": "+1 Laser (Keep Laser)", - "0x17C65": "+1 Laser (Monastery Laser)", - "0x032F9": "+1 Laser (Town Laser)", - "0x00274": "+1 Laser (Jungle Laser)", - "0x0C2B2": "+1 Laser (Bunker Laser)", - "0x00BF6": "+1 Laser (Swamp Laser)", - "0x028A4": "+1 Laser (Treehouse Laser)", - "0x17C34": "Mountain Entry", - "0xFFF00": "Bottom Floor Discard Turns On", - } - - self.USED_EVENT_NAMES_BY_HEX = {} - self.CONDITIONAL_EVENTS = {} - - # The basic requirements to solve each entity come from StaticWitnessLogic. - # However, for any given world, the options (e.g. which item shuffles are enabled) affect the requirements. - self.make_options_adjustments(world) - self.determine_unrequired_entities(world) - self.find_unsolvable_entities(world) - - # After we have adjusted the raw requirements, we perform a dependency reduction for the entity requirements. - # This will make the access conditions way faster, instead of recursively checking dependent entities each time. - self.make_dependency_reduced_checklist() - - # Finalize which items actually exist in the MultiWorld and which get grouped into progressive items. - self.finalize_items() - - # Create event-item pairs for specific panels in the game. - self.make_event_panel_lists() diff --git a/worlds/witness/regions.py b/worlds/witness/regions.py index 35f4e9544212..2528c8abe22b 100644 --- a/worlds/witness/regions.py +++ b/worlds/witness/regions.py @@ -9,9 +9,11 @@ from worlds.generic.Rules import CollectionRule +from .data import static_locations as static_witness_locations from .data import static_logic as static_witness_logic +from .data.static_logic import StaticWitnessLogicObj from .data.utils import WitnessRule, optimize_witness_rule -from .locations import WitnessPlayerLocations, static_witness_locations +from .locations import WitnessPlayerLocations from .player_logic import WitnessPlayerLogic if TYPE_CHECKING: @@ -21,8 +23,20 @@ class WitnessPlayerRegions: """Class that defines Witness Regions""" - player_locations = None - logic = None + def __init__(self, player_locations: WitnessPlayerLocations, world: "WitnessWorld") -> None: + difficulty = world.options.puzzle_randomization + + self.reference_logic: StaticWitnessLogicObj + if difficulty == "sigma_normal": + self.reference_logic = static_witness_logic.sigma_normal + elif difficulty == "sigma_expert": + self.reference_logic = static_witness_logic.sigma_expert + else: + self.reference_logic = static_witness_logic.vanilla + + self.player_locations = player_locations + self.two_way_entrance_register: Dict[Tuple[str, str], List[Entrance]] = defaultdict(lambda: []) + self.created_region_names: Set[str] = set() @staticmethod def make_lambda(item_requirement: WitnessRule, world: "WitnessWorld") -> CollectionRule: @@ -36,7 +50,7 @@ def make_lambda(item_requirement: WitnessRule, world: "WitnessWorld") -> Collect return _meets_item_requirements(item_requirement, world) def connect_if_possible(self, world: "WitnessWorld", source: str, target: str, req: WitnessRule, - regions_by_name: Dict[str, Region]): + regions_by_name: Dict[str, Region]) -> None: """ connect two regions and set the corresponding requirement """ @@ -89,8 +103,8 @@ def create_regions(self, world: "WitnessWorld", player_logic: WitnessPlayerLogic """ from . import create_region - all_locations = set() - regions_by_name = dict() + all_locations: Set[str] = set() + regions_by_name: Dict[str, Region] = {} regions_to_create = { k: v for k, v in self.reference_logic.ALL_REGIONS_BY_NAME.items() @@ -121,17 +135,3 @@ def create_regions(self, world: "WitnessWorld", player_logic: WitnessPlayerLogic for region_name, region in regions_to_create.items(): for connection in player_logic.CONNECTIONS_BY_REGION_NAME[region_name]: self.connect_if_possible(world, region_name, connection[0], connection[1], regions_by_name) - - def __init__(self, player_locations: WitnessPlayerLocations, world: "WitnessWorld") -> None: - difficulty = world.options.puzzle_randomization - - if difficulty == "sigma_normal": - self.reference_logic = static_witness_logic.sigma_normal - elif difficulty == "sigma_expert": - self.reference_logic = static_witness_logic.sigma_expert - elif difficulty == "none": - self.reference_logic = static_witness_logic.vanilla - - self.player_locations = player_locations - self.two_way_entrance_register: Dict[Tuple[str, str], List[Entrance]] = defaultdict(lambda: []) - self.created_region_names: Set[str] = set() diff --git a/worlds/witness/ruff.toml b/worlds/witness/ruff.toml index d42361a4aaa9..a35711cce66d 100644 --- a/worlds/witness/ruff.toml +++ b/worlds/witness/ruff.toml @@ -1,10 +1,10 @@ line-length = 120 [lint] -select = ["E", "F", "W", "I", "N", "Q", "UP", "RUF", "ISC", "T20"] -ignore = ["RUF012", "RUF100"] +select = ["C", "E", "F", "R", "W", "I", "N", "Q", "UP", "RUF", "ISC", "T20"] +ignore = ["C9", "RUF012", "RUF100"] -[per-file-ignores] +[lint.per-file-ignores] # The way options definitions work right now, I am forced to break line length requirements. "options.py" = ["E501"] # The import list would just be so big if I imported every option individually in presets.py diff --git a/worlds/witness/rules.py b/worlds/witness/rules.py index b4982d1830b2..12a9a1ed4b59 100644 --- a/worlds/witness/rules.py +++ b/worlds/witness/rules.py @@ -37,8 +37,8 @@ def _has_laser(laser_hex: str, world: "WitnessWorld", player: int, redirect_requ _can_solve_panel(laser_hex, world, world.player, world.player_logic, world.player_locations)(state) and state.has("Desert Laser Redirection", player) ) - else: - return _can_solve_panel(laser_hex, world, world.player, world.player_logic, world.player_locations) + + return _can_solve_panel(laser_hex, world, world.player, world.player_logic, world.player_locations) def _has_lasers(amount: int, world: "WitnessWorld", redirect_required: bool) -> CollectionRule: @@ -63,8 +63,8 @@ def _can_solve_panel(panel: str, world: "WitnessWorld", player: int, player_logi if entity_name + " Solved" in player_locations.EVENT_LOCATION_TABLE: return lambda state: state.has(player_logic.EVENT_ITEM_PAIRS[entity_name + " Solved"], player) - else: - return make_lambda(panel, world) + + return make_lambda(panel, world) def _can_do_expert_pp2(state: CollectionState, world: "WitnessWorld") -> bool: @@ -175,12 +175,10 @@ def _can_do_expert_pp2(state: CollectionState, world: "WitnessWorld") -> bool: # We can get to Hedge 3 from Hedge 2. If we can get from Keep to Hedge 2, we're good. # This covers both Hedge 1 Exit and Hedge 2 Shortcut, because Hedge 1 is just part of the Keep region. - hedge_2_from_keep = any( + return any( e.can_reach(state) for e in player_regions.two_way_entrance_register["Keep 2nd Maze", "Keep"] ) - return hedge_2_from_keep - def _can_do_theater_to_tunnels(state: CollectionState, world: "WitnessWorld") -> bool: """ @@ -211,14 +209,12 @@ def _can_do_theater_to_tunnels(state: CollectionState, world: "WitnessWorld") -> # We also need a way from Town to Tunnels. - tunnels_from_town = ( + return ( any(e.can_reach(state) for e in player_regions.two_way_entrance_register["Tunnels", "Windmill Interior"]) and any(e.can_reach(state) for e in player_regions.two_way_entrance_register["Town", "Windmill Interior"]) or any(e.can_reach(state) for e in player_regions.two_way_entrance_register["Tunnels", "Town"]) ) - return tunnels_from_town - def _has_item(item: str, world: "WitnessWorld", player: int, player_logic: WitnessPlayerLogic, player_locations: WitnessPlayerLocations) -> CollectionRule: @@ -237,9 +233,9 @@ def _has_item(item: str, world: "WitnessWorld", player: int, if item == "11 Lasers + Redirect": laser_req = world.options.challenge_lasers.value return _has_lasers(laser_req, world, True) - elif item == "PP2 Weirdness": + if item == "PP2 Weirdness": return lambda state: _can_do_expert_pp2(state, world) - elif item == "Theater to Tunnels": + if item == "Theater to Tunnels": return lambda state: _can_do_theater_to_tunnels(state, world) if item in player_logic.USED_EVENT_NAMES_BY_HEX: return _can_solve_panel(item, world, player, player_logic, player_locations) From 95110c478733a315cb61ccd919fd3ca5f4801dfa Mon Sep 17 00:00:00 2001 From: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com> Date: Wed, 3 Jul 2024 00:34:17 +0200 Subject: [PATCH 13/14] The Witness: Fix door shuffle being completely broken --- worlds/witness/player_logic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/worlds/witness/player_logic.py b/worlds/witness/player_logic.py index b62c59b00ae1..e8d11f43f51c 100644 --- a/worlds/witness/player_logic.py +++ b/worlds/witness/player_logic.py @@ -197,7 +197,7 @@ def reduce_req_within_region(self, entity_hex: str) -> WitnessRule: # Those requirements need to be preserved even in door shuffle. entity_dependencies_need_to_be_preserved = ( # EPs keep all their entity dependencies - static_witness_logic.ENTITIES_BY_HEX[entity_hex]["entityType"] != "EP" + static_witness_logic.ENTITIES_BY_HEX[entity_hex]["entityType"] == "EP" # 0x28A0D depends on another entity for *non-power* reasons -> This dependency needs to be preserved, # except in Expert, where that dependency doesn't exist, but now there *is* a power dependency. # In the future, it'd be wise to make a distinction between "power dependencies" and other dependencies. From 50f7a79ea75e2b7eb0ff4c3881408485b4e9ec4e Mon Sep 17 00:00:00 2001 From: Doug Hoskisson Date: Tue, 2 Jul 2024 19:32:01 -0700 Subject: [PATCH 14/14] Zillion: new map generation feature (#3604) --- worlds/zillion/__init__.py | 6 +++--- worlds/zillion/options.py | 34 +++++++++++++++++++++++++-------- worlds/zillion/requirements.txt | 2 +- 3 files changed, 30 insertions(+), 12 deletions(-) diff --git a/worlds/zillion/__init__.py b/worlds/zillion/__init__.py index 205cc9ad6ba1..cf61d93ca4ce 100644 --- a/worlds/zillion/__init__.py +++ b/worlds/zillion/__init__.py @@ -145,10 +145,10 @@ def generate_early(self) -> None: self._item_counts = item_counts with redirect_stdout(self.lsi): # type: ignore - self.zz_system.make_randomizer(zz_op) - - self.zz_system.seed(self.multiworld.seed) + self.zz_system.set_options(zz_op) + self.zz_system.seed(self.random.randrange(1999999999)) self.zz_system.make_map() + self.zz_system.make_randomizer() # just in case the options changed anything (I don't think they do) assert self.zz_system.randomizer, "init failed" diff --git a/worlds/zillion/options.py b/worlds/zillion/options.py index d75dd1a1c22c..5de0b65c82f0 100644 --- a/worlds/zillion/options.py +++ b/worlds/zillion/options.py @@ -1,9 +1,9 @@ from collections import Counter from dataclasses import dataclass -from typing import ClassVar, Dict, Tuple +from typing import ClassVar, Dict, Literal, Tuple from typing_extensions import TypeGuard # remove when Python >= 3.10 -from Options import Choice, DefaultOnToggle, NamedRange, OptionGroup, PerGameCommonOptions, Range, Toggle +from Options import Choice, DefaultOnToggle, NamedRange, OptionGroup, PerGameCommonOptions, Range, Removed, Toggle from zilliandomizer.options import ( Options as ZzOptions, char_to_gun, char_to_jump, ID, @@ -251,9 +251,25 @@ class ZillionStartingCards(NamedRange): } -class ZillionRoomGen(Toggle): - """ whether to generate rooms with random terrain """ - display_name = "room generation" +class ZillionMapGen(Choice): + """ + - none: vanilla map + - rooms: random terrain inside rooms, but path through base is vanilla + - full: random path through base + """ + display_name = "map generation" + option_none = 0 + option_rooms = 1 + option_full = 2 + default = 0 + + def zz_value(self) -> Literal['none', 'rooms', 'full']: + if self.value == ZillionMapGen.option_none: + return "none" + if self.value == ZillionMapGen.option_rooms: + return "rooms" + assert self.value == ZillionMapGen.option_full + return "full" @dataclass @@ -276,7 +292,9 @@ class ZillionOptions(PerGameCommonOptions): early_scope: ZillionEarlyScope skill: ZillionSkill starting_cards: ZillionStartingCards - room_gen: ZillionRoomGen + map_gen: ZillionMapGen + + room_gen: Removed z_option_groups = [ @@ -375,7 +393,7 @@ def validate(options: ZillionOptions) -> "Tuple[ZzOptions, Counter[str]]": starting_cards = options.starting_cards - room_gen = options.room_gen + map_gen = options.map_gen.zz_value() zz_item_counts = convert_item_counts(item_counts) zz_op = ZzOptions( @@ -393,7 +411,7 @@ def validate(options: ZillionOptions) -> "Tuple[ZzOptions, Counter[str]]": bool(options.early_scope.value), True, # balance defense starting_cards.value, - bool(room_gen.value) + map_gen ) zz_validate(zz_op) return zz_op, item_counts diff --git a/worlds/zillion/requirements.txt b/worlds/zillion/requirements.txt index ae7d9b173308..b4f554902f48 100644 --- a/worlds/zillion/requirements.txt +++ b/worlds/zillion/requirements.txt @@ -1,2 +1,2 @@ -zilliandomizer @ git+https://github.com/beauxq/zilliandomizer@1dd2ce01c9d818caba5844529699b3ad026d6a07#0.7.1 +zilliandomizer @ git+https://github.com/beauxq/zilliandomizer@4a2fec0aa1c529df866e510cdfcf6dca4d53679b#0.8.0 typing-extensions>=4.7, <5