From 1416f631cc539027b1ad843d9bb9d14c45df7a9d Mon Sep 17 00:00:00 2001 From: Justus Lind Date: Mon, 24 Mar 2025 12:30:44 +1000 Subject: [PATCH 01/53] Core: Add a test that checks all registered patches matches the name of a registered world (#4633) Co-authored-by: qwint --- test/general/test_patches.py | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 test/general/test_patches.py diff --git a/test/general/test_patches.py b/test/general/test_patches.py new file mode 100644 index 0000000000..09782b8d3d --- /dev/null +++ b/test/general/test_patches.py @@ -0,0 +1,11 @@ +import unittest +from worlds.AutoWorld import AutoWorldRegister +from worlds.Files import AutoPatchRegister + + +class TestPatches(unittest.TestCase): + def test_patch_name_matches_game(self) -> None: + for game_name in AutoPatchRegister.patch_types: + with self.subTest(game=game_name): + self.assertIn(game_name, AutoWorldRegister.world_types.keys(), + f"Patch '{game_name}' does not match the name of any world.") From e12ab4afa453197016384ae4d9040efeb061dce9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9mie=20Bolduc?= <16137441+Jouramie@users.noreply.github.com> Date: Sun, 23 Mar 2025 22:32:34 -0400 Subject: [PATCH 02/53] Stardew Valley: Move test option presets to their own file (#4349) --- worlds/stardew_valley/options/__init__.py | 2 +- worlds/stardew_valley/test/TestFill.py | 3 +- worlds/stardew_valley/test/TestItems.py | 3 +- worlds/stardew_valley/test/TestLogic.py | 4 +- .../test/TestNumberLocations.py | 6 +- worlds/stardew_valley/test/TestOptions.py | 3 +- worlds/stardew_valley/test/__init__.py | 166 +----------------- worlds/stardew_valley/test/mods/TestMods.py | 4 +- worlds/stardew_valley/test/options/presets.py | 164 +++++++++++++++++ .../test/performance/TestPerformance.py | 3 +- .../test/rules/TestStateRules.py | 3 +- .../test/stability/StabilityOutputScript.py | 3 +- .../test/stability/TestUniversalTracker.py | 3 +- 13 files changed, 189 insertions(+), 178 deletions(-) create mode 100644 worlds/stardew_valley/test/options/presets.py diff --git a/worlds/stardew_valley/options/__init__.py b/worlds/stardew_valley/options/__init__.py index d1436b00df..713d3e9537 100644 --- a/worlds/stardew_valley/options/__init__.py +++ b/worlds/stardew_valley/options/__init__.py @@ -3,4 +3,4 @@ from .options import StardewValleyOption, Goal, FarmType, StartingMoney, ProfitM ArcadeMachineLocations, SpecialOrderLocations, QuestLocations, Fishsanity, Museumsanity, Monstersanity, Shipsanity, Cooksanity, Chefsanity, Craftsanity, \ Friendsanity, FriendsanityHeartSize, Booksanity, Walnutsanity, NumberOfMovementBuffs, EnabledFillerBuffs, ExcludeGingerIsland, TrapItems, \ MultipleDaySleepEnabled, MultipleDaySleepCost, ExperienceMultiplier, FriendshipMultiplier, DebrisMultiplier, QuickStart, Gifting, Mods, BundlePlando, \ - StardewValleyOptions + StardewValleyOptions, enabled_mods, disabled_mods, all_mods diff --git a/worlds/stardew_valley/test/TestFill.py b/worlds/stardew_valley/test/TestFill.py index 0bfacb6ef6..f8565f4f21 100644 --- a/worlds/stardew_valley/test/TestFill.py +++ b/worlds/stardew_valley/test/TestFill.py @@ -1,5 +1,6 @@ -from . import SVTestBase, minimal_locations_maximal_items +from . import SVTestBase from .assertion import WorldAssertMixin +from .options.presets import minimal_locations_maximal_items from .. import options from ..mods.mod_data import ModNames diff --git a/worlds/stardew_valley/test/TestItems.py b/worlds/stardew_valley/test/TestItems.py index 671fe63872..9cff146597 100644 --- a/worlds/stardew_valley/test/TestItems.py +++ b/worlds/stardew_valley/test/TestItems.py @@ -1,5 +1,6 @@ from BaseClasses import MultiWorld, get_seed -from . import setup_solo_multiworld, SVTestCase, allsanity_no_mods_6_x_x, get_minsanity_options, solo_multiworld +from . import setup_solo_multiworld, SVTestCase, solo_multiworld +from .options.presets import allsanity_no_mods_6_x_x, get_minsanity_options from .. import StardewValleyWorld from ..items import Group, item_table from ..options import Friendsanity, SeasonRandomization, Museumsanity, Shipsanity, Goal diff --git a/worlds/stardew_valley/test/TestLogic.py b/worlds/stardew_valley/test/TestLogic.py index da00a0f43e..85279ad238 100644 --- a/worlds/stardew_valley/test/TestLogic.py +++ b/worlds/stardew_valley/test/TestLogic.py @@ -3,7 +3,9 @@ import unittest from unittest import TestCase, SkipTest from BaseClasses import MultiWorld -from . import RuleAssertMixin, setup_solo_multiworld, allsanity_mods_6_x_x, minimal_locations_maximal_items +from . import setup_solo_multiworld +from .assertion import RuleAssertMixin +from .options.presets import allsanity_mods_6_x_x, minimal_locations_maximal_items from .. import StardewValleyWorld from ..data.bundle_data import all_bundle_items_except_money from ..logic.logic import StardewLogic diff --git a/worlds/stardew_valley/test/TestNumberLocations.py b/worlds/stardew_valley/test/TestNumberLocations.py index a1c6a96741..2ed528086a 100644 --- a/worlds/stardew_valley/test/TestNumberLocations.py +++ b/worlds/stardew_valley/test/TestNumberLocations.py @@ -1,6 +1,6 @@ -from . import SVTestBase, allsanity_no_mods_6_x_x, \ - allsanity_mods_6_x_x, minimal_locations_maximal_items, minimal_locations_maximal_items_with_island, get_minsanity_options, default_6_x_x, \ - allsanity_mods_6_x_x_exclude_disabled +from . import SVTestBase +from .options.presets import default_6_x_x, allsanity_no_mods_6_x_x, allsanity_mods_6_x_x_exclude_disabled, get_minsanity_options, \ + minimal_locations_maximal_items, minimal_locations_maximal_items_with_island from .. import location_table from ..items import Group, item_table diff --git a/worlds/stardew_valley/test/TestOptions.py b/worlds/stardew_valley/test/TestOptions.py index 06bbfd457a..9d9af04a4e 100644 --- a/worlds/stardew_valley/test/TestOptions.py +++ b/worlds/stardew_valley/test/TestOptions.py @@ -2,9 +2,10 @@ import itertools from BaseClasses import ItemClassification from Options import NamedRange -from . import SVTestCase, allsanity_no_mods_6_x_x, allsanity_mods_6_x_x, solo_multiworld, SVTestBase +from . import SVTestCase, solo_multiworld, SVTestBase from .assertion import WorldAssertMixin from .long.option_names import all_option_choices +from .options.presets import allsanity_no_mods_6_x_x, allsanity_mods_6_x_x from .. import items_by_group, Group, StardewValleyWorld from ..locations import locations_by_tag, LocationTags, location_table from ..options import ExcludeGingerIsland, ToolProgression, Goal, SeasonRandomization, TrapItems, SpecialOrderLocations, ArcadeMachineLocations diff --git a/worlds/stardew_valley/test/__init__.py b/worlds/stardew_valley/test/__init__.py index 880a3fda5c..f06e7d0768 100644 --- a/worlds/stardew_valley/test/__init__.py +++ b/worlds/stardew_valley/test/__init__.py @@ -11,9 +11,8 @@ from test.general import gen_steps, setup_solo_multiworld as setup_base_solo_mul from worlds.AutoWorld import call_all from .assertion import RuleAssertMixin from .options.utils import fill_namespace_with_default, parse_class_option_keys, fill_dataclass_with_default -from .. import StardewValleyWorld, options, StardewItem +from .. import StardewValleyWorld, StardewItem from ..options import StardewValleyOption -from ..options.options import enabled_mods logger = logging.getLogger(__name__) @@ -21,169 +20,6 @@ DEFAULT_TEST_SEED = get_seed() logger.info(f"Default Test Seed: {DEFAULT_TEST_SEED}") -def default_6_x_x(): - return { - options.ArcadeMachineLocations.internal_name: options.ArcadeMachineLocations.default, - options.BackpackProgression.internal_name: options.BackpackProgression.default, - options.Booksanity.internal_name: options.Booksanity.default, - options.BuildingProgression.internal_name: options.BuildingProgression.default, - options.BundlePrice.internal_name: options.BundlePrice.default, - options.BundleRandomization.internal_name: options.BundleRandomization.default, - options.Chefsanity.internal_name: options.Chefsanity.default, - options.Cooksanity.internal_name: options.Cooksanity.default, - options.Craftsanity.internal_name: options.Craftsanity.default, - options.Cropsanity.internal_name: options.Cropsanity.default, - options.ElevatorProgression.internal_name: options.ElevatorProgression.default, - options.EntranceRandomization.internal_name: options.EntranceRandomization.default, - options.ExcludeGingerIsland.internal_name: options.ExcludeGingerIsland.default, - options.FestivalLocations.internal_name: options.FestivalLocations.default, - options.Fishsanity.internal_name: options.Fishsanity.default, - options.Friendsanity.internal_name: options.Friendsanity.default, - options.FriendsanityHeartSize.internal_name: options.FriendsanityHeartSize.default, - options.Goal.internal_name: options.Goal.default, - options.Mods.internal_name: options.Mods.default, - options.Monstersanity.internal_name: options.Monstersanity.default, - options.Museumsanity.internal_name: options.Museumsanity.default, - options.NumberOfMovementBuffs.internal_name: options.NumberOfMovementBuffs.default, - options.EnabledFillerBuffs.internal_name: options.EnabledFillerBuffs.default, - options.QuestLocations.internal_name: options.QuestLocations.default, - options.SeasonRandomization.internal_name: options.SeasonRandomization.default, - options.Shipsanity.internal_name: options.Shipsanity.default, - options.SkillProgression.internal_name: options.SkillProgression.default, - options.SpecialOrderLocations.internal_name: options.SpecialOrderLocations.default, - options.ToolProgression.internal_name: options.ToolProgression.default, - options.TrapItems.internal_name: options.TrapItems.default, - options.Walnutsanity.internal_name: options.Walnutsanity.default - } - - -def allsanity_no_mods_6_x_x(): - return { - options.ArcadeMachineLocations.internal_name: options.ArcadeMachineLocations.option_full_shuffling, - options.BackpackProgression.internal_name: options.BackpackProgression.option_progressive, - options.Booksanity.internal_name: options.Booksanity.option_all, - options.BuildingProgression.internal_name: options.BuildingProgression.option_progressive, - options.BundlePrice.internal_name: options.BundlePrice.option_expensive, - options.BundleRandomization.internal_name: options.BundleRandomization.option_thematic, - options.Chefsanity.internal_name: options.Chefsanity.option_all, - options.Cooksanity.internal_name: options.Cooksanity.option_all, - options.Craftsanity.internal_name: options.Craftsanity.option_all, - options.Cropsanity.internal_name: options.Cropsanity.option_enabled, - options.ElevatorProgression.internal_name: options.ElevatorProgression.option_progressive, - options.EntranceRandomization.internal_name: options.EntranceRandomization.option_disabled, - options.ExcludeGingerIsland.internal_name: options.ExcludeGingerIsland.option_false, - options.FestivalLocations.internal_name: options.FestivalLocations.option_hard, - options.Fishsanity.internal_name: options.Fishsanity.option_all, - options.Friendsanity.internal_name: options.Friendsanity.option_all_with_marriage, - options.FriendsanityHeartSize.internal_name: 1, - options.Goal.internal_name: options.Goal.option_perfection, - options.Mods.internal_name: frozenset(), - options.Monstersanity.internal_name: options.Monstersanity.option_progressive_goals, - options.Museumsanity.internal_name: options.Museumsanity.option_all, - options.EnabledFillerBuffs.internal_name: options.EnabledFillerBuffs.preset_all, - options.NumberOfMovementBuffs.internal_name: 12, - options.QuestLocations.internal_name: 56, - options.SeasonRandomization.internal_name: options.SeasonRandomization.option_randomized, - options.Shipsanity.internal_name: options.Shipsanity.option_everything, - options.SkillProgression.internal_name: options.SkillProgression.option_progressive_with_masteries, - options.SpecialOrderLocations.internal_name: options.SpecialOrderLocations.option_board_qi, - options.ToolProgression.internal_name: options.ToolProgression.option_progressive, - options.TrapItems.internal_name: options.TrapItems.option_nightmare, - options.Walnutsanity.internal_name: options.Walnutsanity.preset_all - } - - -def allsanity_mods_6_x_x(): - allsanity = allsanity_no_mods_6_x_x() - allsanity.update({options.Mods.internal_name: frozenset(options.Mods.valid_keys)}) - return allsanity - - -def allsanity_mods_6_x_x_exclude_disabled(): - allsanity = allsanity_no_mods_6_x_x() - allsanity.update({options.Mods.internal_name: frozenset(enabled_mods)}) - return allsanity - - -def get_minsanity_options(): - return { - options.ArcadeMachineLocations.internal_name: options.ArcadeMachineLocations.option_disabled, - options.BackpackProgression.internal_name: options.BackpackProgression.option_vanilla, - options.Booksanity.internal_name: options.Booksanity.option_none, - options.BuildingProgression.internal_name: options.BuildingProgression.option_vanilla, - options.BundlePrice.internal_name: options.BundlePrice.option_very_cheap, - options.BundleRandomization.internal_name: options.BundleRandomization.option_vanilla, - options.Chefsanity.internal_name: options.Chefsanity.option_none, - options.Cooksanity.internal_name: options.Cooksanity.option_none, - options.Craftsanity.internal_name: options.Craftsanity.option_none, - options.Cropsanity.internal_name: options.Cropsanity.option_disabled, - options.ElevatorProgression.internal_name: options.ElevatorProgression.option_vanilla, - options.EntranceRandomization.internal_name: options.EntranceRandomization.option_disabled, - options.ExcludeGingerIsland.internal_name: options.ExcludeGingerIsland.option_true, - options.FestivalLocations.internal_name: options.FestivalLocations.option_disabled, - options.Fishsanity.internal_name: options.Fishsanity.option_none, - options.Friendsanity.internal_name: options.Friendsanity.option_none, - options.FriendsanityHeartSize.internal_name: 8, - options.Goal.internal_name: options.Goal.option_bottom_of_the_mines, - options.Mods.internal_name: frozenset(), - options.Monstersanity.internal_name: options.Monstersanity.option_none, - options.Museumsanity.internal_name: options.Museumsanity.option_none, - options.EnabledFillerBuffs.internal_name: options.EnabledFillerBuffs.preset_none, - options.NumberOfMovementBuffs.internal_name: 0, - options.QuestLocations.internal_name: -1, - options.SeasonRandomization.internal_name: options.SeasonRandomization.option_disabled, - options.Shipsanity.internal_name: options.Shipsanity.option_none, - options.SkillProgression.internal_name: options.SkillProgression.option_vanilla, - options.SpecialOrderLocations.internal_name: options.SpecialOrderLocations.option_vanilla, - options.ToolProgression.internal_name: options.ToolProgression.option_vanilla, - options.TrapItems.internal_name: options.TrapItems.option_no_traps, - options.Walnutsanity.internal_name: options.Walnutsanity.preset_none - } - - -def minimal_locations_maximal_items(): - min_max_options = { - options.ArcadeMachineLocations.internal_name: options.ArcadeMachineLocations.option_disabled, - options.BackpackProgression.internal_name: options.BackpackProgression.option_vanilla, - options.Booksanity.internal_name: options.Booksanity.option_none, - options.BuildingProgression.internal_name: options.BuildingProgression.option_vanilla, - options.BundlePrice.internal_name: options.BundlePrice.option_expensive, - options.BundleRandomization.internal_name: options.BundleRandomization.option_shuffled, - options.Chefsanity.internal_name: options.Chefsanity.option_none, - options.Cooksanity.internal_name: options.Cooksanity.option_none, - options.Craftsanity.internal_name: options.Craftsanity.option_none, - options.Cropsanity.internal_name: options.Cropsanity.option_disabled, - options.ElevatorProgression.internal_name: options.ElevatorProgression.option_vanilla, - options.EntranceRandomization.internal_name: options.EntranceRandomization.option_disabled, - options.ExcludeGingerIsland.internal_name: options.ExcludeGingerIsland.option_true, - options.FestivalLocations.internal_name: options.FestivalLocations.option_disabled, - options.Fishsanity.internal_name: options.Fishsanity.option_none, - options.Friendsanity.internal_name: options.Friendsanity.option_none, - options.FriendsanityHeartSize.internal_name: 8, - options.Goal.internal_name: options.Goal.option_craft_master, - options.Mods.internal_name: frozenset(), - options.Monstersanity.internal_name: options.Monstersanity.option_none, - options.Museumsanity.internal_name: options.Museumsanity.option_none, - options.EnabledFillerBuffs.internal_name: options.EnabledFillerBuffs.preset_all, - options.NumberOfMovementBuffs.internal_name: 12, - options.QuestLocations.internal_name: -1, - options.SeasonRandomization.internal_name: options.SeasonRandomization.option_randomized, - options.Shipsanity.internal_name: options.Shipsanity.option_none, - options.SkillProgression.internal_name: options.SkillProgression.option_vanilla, - options.SpecialOrderLocations.internal_name: options.SpecialOrderLocations.option_vanilla, - options.ToolProgression.internal_name: options.ToolProgression.option_vanilla, - options.TrapItems.internal_name: options.TrapItems.option_nightmare, - options.Walnutsanity.internal_name: options.Walnutsanity.preset_none - } - return min_max_options - - -def minimal_locations_maximal_items_with_island(): - min_max_options = minimal_locations_maximal_items() - min_max_options.update({options.ExcludeGingerIsland.internal_name: options.ExcludeGingerIsland.option_false}) - return min_max_options - - class SVTestCase(unittest.TestCase): # Set False to not skip some 'extra' tests skip_base_tests: bool = True diff --git a/worlds/stardew_valley/test/mods/TestMods.py b/worlds/stardew_valley/test/mods/TestMods.py index dc958652e1..b4d10f2e99 100644 --- a/worlds/stardew_valley/test/mods/TestMods.py +++ b/worlds/stardew_valley/test/mods/TestMods.py @@ -1,8 +1,10 @@ import random from BaseClasses import get_seed, ItemClassification -from .. import SVTestBase, SVTestCase, allsanity_mods_6_x_x, fill_dataclass_with_default +from .. import SVTestBase, SVTestCase from ..assertion import ModAssertMixin, WorldAssertMixin +from ..options.presets import allsanity_mods_6_x_x +from ..options.utils import fill_dataclass_with_default from ... import options, items, Group, create_content from ...mods.mod_data import ModNames from ...options import SkillProgression, Walnutsanity diff --git a/worlds/stardew_valley/test/options/presets.py b/worlds/stardew_valley/test/options/presets.py new file mode 100644 index 0000000000..aecdeadd9f --- /dev/null +++ b/worlds/stardew_valley/test/options/presets.py @@ -0,0 +1,164 @@ +from ... import options + + +def default_6_x_x(): + return { + options.ArcadeMachineLocations.internal_name: options.ArcadeMachineLocations.default, + options.BackpackProgression.internal_name: options.BackpackProgression.default, + options.Booksanity.internal_name: options.Booksanity.default, + options.BuildingProgression.internal_name: options.BuildingProgression.default, + options.BundlePrice.internal_name: options.BundlePrice.default, + options.BundleRandomization.internal_name: options.BundleRandomization.default, + options.Chefsanity.internal_name: options.Chefsanity.default, + options.Cooksanity.internal_name: options.Cooksanity.default, + options.Craftsanity.internal_name: options.Craftsanity.default, + options.Cropsanity.internal_name: options.Cropsanity.default, + options.ElevatorProgression.internal_name: options.ElevatorProgression.default, + options.EntranceRandomization.internal_name: options.EntranceRandomization.default, + options.ExcludeGingerIsland.internal_name: options.ExcludeGingerIsland.default, + options.FestivalLocations.internal_name: options.FestivalLocations.default, + options.Fishsanity.internal_name: options.Fishsanity.default, + options.Friendsanity.internal_name: options.Friendsanity.default, + options.FriendsanityHeartSize.internal_name: options.FriendsanityHeartSize.default, + options.Goal.internal_name: options.Goal.default, + options.Mods.internal_name: options.Mods.default, + options.Monstersanity.internal_name: options.Monstersanity.default, + options.Museumsanity.internal_name: options.Museumsanity.default, + options.NumberOfMovementBuffs.internal_name: options.NumberOfMovementBuffs.default, + options.EnabledFillerBuffs.internal_name: options.EnabledFillerBuffs.default, + options.QuestLocations.internal_name: options.QuestLocations.default, + options.SeasonRandomization.internal_name: options.SeasonRandomization.default, + options.Shipsanity.internal_name: options.Shipsanity.default, + options.SkillProgression.internal_name: options.SkillProgression.default, + options.SpecialOrderLocations.internal_name: options.SpecialOrderLocations.default, + options.ToolProgression.internal_name: options.ToolProgression.default, + options.TrapItems.internal_name: options.TrapItems.default, + options.Walnutsanity.internal_name: options.Walnutsanity.default + } + + +def allsanity_no_mods_6_x_x(): + return { + options.ArcadeMachineLocations.internal_name: options.ArcadeMachineLocations.option_full_shuffling, + options.BackpackProgression.internal_name: options.BackpackProgression.option_progressive, + options.Booksanity.internal_name: options.Booksanity.option_all, + options.BuildingProgression.internal_name: options.BuildingProgression.option_progressive, + options.BundlePrice.internal_name: options.BundlePrice.option_expensive, + options.BundleRandomization.internal_name: options.BundleRandomization.option_thematic, + options.Chefsanity.internal_name: options.Chefsanity.option_all, + options.Cooksanity.internal_name: options.Cooksanity.option_all, + options.Craftsanity.internal_name: options.Craftsanity.option_all, + options.Cropsanity.internal_name: options.Cropsanity.option_enabled, + options.ElevatorProgression.internal_name: options.ElevatorProgression.option_progressive, + options.EntranceRandomization.internal_name: options.EntranceRandomization.option_disabled, + options.ExcludeGingerIsland.internal_name: options.ExcludeGingerIsland.option_false, + options.FestivalLocations.internal_name: options.FestivalLocations.option_hard, + options.Fishsanity.internal_name: options.Fishsanity.option_all, + options.Friendsanity.internal_name: options.Friendsanity.option_all_with_marriage, + options.FriendsanityHeartSize.internal_name: 1, + options.Goal.internal_name: options.Goal.option_perfection, + options.Mods.internal_name: frozenset(), + options.Monstersanity.internal_name: options.Monstersanity.option_progressive_goals, + options.Museumsanity.internal_name: options.Museumsanity.option_all, + options.EnabledFillerBuffs.internal_name: options.EnabledFillerBuffs.preset_all, + options.NumberOfMovementBuffs.internal_name: 12, + options.QuestLocations.internal_name: 56, + options.SeasonRandomization.internal_name: options.SeasonRandomization.option_randomized, + options.Shipsanity.internal_name: options.Shipsanity.option_everything, + options.SkillProgression.internal_name: options.SkillProgression.option_progressive_with_masteries, + options.SpecialOrderLocations.internal_name: options.SpecialOrderLocations.option_board_qi, + options.ToolProgression.internal_name: options.ToolProgression.option_progressive, + options.TrapItems.internal_name: options.TrapItems.option_nightmare, + options.Walnutsanity.internal_name: options.Walnutsanity.preset_all + } + + +def allsanity_mods_6_x_x_exclude_disabled(): + allsanity = allsanity_no_mods_6_x_x() + allsanity.update({options.Mods.internal_name: frozenset(options.enabled_mods)}) + return allsanity + + +def allsanity_mods_6_x_x(): + allsanity = allsanity_no_mods_6_x_x() + allsanity.update({options.Mods.internal_name: frozenset(options.all_mods)}) + return allsanity + + +def get_minsanity_options(): + return { + options.ArcadeMachineLocations.internal_name: options.ArcadeMachineLocations.option_disabled, + options.BackpackProgression.internal_name: options.BackpackProgression.option_vanilla, + options.Booksanity.internal_name: options.Booksanity.option_none, + options.BuildingProgression.internal_name: options.BuildingProgression.option_vanilla, + options.BundlePrice.internal_name: options.BundlePrice.option_very_cheap, + options.BundleRandomization.internal_name: options.BundleRandomization.option_vanilla, + options.Chefsanity.internal_name: options.Chefsanity.option_none, + options.Cooksanity.internal_name: options.Cooksanity.option_none, + options.Craftsanity.internal_name: options.Craftsanity.option_none, + options.Cropsanity.internal_name: options.Cropsanity.option_disabled, + options.ElevatorProgression.internal_name: options.ElevatorProgression.option_vanilla, + options.EntranceRandomization.internal_name: options.EntranceRandomization.option_disabled, + options.ExcludeGingerIsland.internal_name: options.ExcludeGingerIsland.option_true, + options.FestivalLocations.internal_name: options.FestivalLocations.option_disabled, + options.Fishsanity.internal_name: options.Fishsanity.option_none, + options.Friendsanity.internal_name: options.Friendsanity.option_none, + options.FriendsanityHeartSize.internal_name: 8, + options.Goal.internal_name: options.Goal.option_bottom_of_the_mines, + options.Mods.internal_name: frozenset(), + options.Monstersanity.internal_name: options.Monstersanity.option_none, + options.Museumsanity.internal_name: options.Museumsanity.option_none, + options.EnabledFillerBuffs.internal_name: options.EnabledFillerBuffs.preset_none, + options.NumberOfMovementBuffs.internal_name: 0, + options.QuestLocations.internal_name: -1, + options.SeasonRandomization.internal_name: options.SeasonRandomization.option_disabled, + options.Shipsanity.internal_name: options.Shipsanity.option_none, + options.SkillProgression.internal_name: options.SkillProgression.option_vanilla, + options.SpecialOrderLocations.internal_name: options.SpecialOrderLocations.option_vanilla, + options.ToolProgression.internal_name: options.ToolProgression.option_vanilla, + options.TrapItems.internal_name: options.TrapItems.option_no_traps, + options.Walnutsanity.internal_name: options.Walnutsanity.preset_none + } + + +def minimal_locations_maximal_items(): + min_max_options = { + options.ArcadeMachineLocations.internal_name: options.ArcadeMachineLocations.option_disabled, + options.BackpackProgression.internal_name: options.BackpackProgression.option_vanilla, + options.Booksanity.internal_name: options.Booksanity.option_none, + options.BuildingProgression.internal_name: options.BuildingProgression.option_vanilla, + options.BundlePrice.internal_name: options.BundlePrice.option_expensive, + options.BundleRandomization.internal_name: options.BundleRandomization.option_shuffled, + options.Chefsanity.internal_name: options.Chefsanity.option_none, + options.Cooksanity.internal_name: options.Cooksanity.option_none, + options.Craftsanity.internal_name: options.Craftsanity.option_none, + options.Cropsanity.internal_name: options.Cropsanity.option_disabled, + options.ElevatorProgression.internal_name: options.ElevatorProgression.option_vanilla, + options.EntranceRandomization.internal_name: options.EntranceRandomization.option_disabled, + options.ExcludeGingerIsland.internal_name: options.ExcludeGingerIsland.option_true, + options.FestivalLocations.internal_name: options.FestivalLocations.option_disabled, + options.Fishsanity.internal_name: options.Fishsanity.option_none, + options.Friendsanity.internal_name: options.Friendsanity.option_none, + options.FriendsanityHeartSize.internal_name: 8, + options.Goal.internal_name: options.Goal.option_craft_master, + options.Mods.internal_name: frozenset(), + options.Monstersanity.internal_name: options.Monstersanity.option_none, + options.Museumsanity.internal_name: options.Museumsanity.option_none, + options.EnabledFillerBuffs.internal_name: options.EnabledFillerBuffs.preset_all, + options.NumberOfMovementBuffs.internal_name: 12, + options.QuestLocations.internal_name: -1, + options.SeasonRandomization.internal_name: options.SeasonRandomization.option_randomized, + options.Shipsanity.internal_name: options.Shipsanity.option_none, + options.SkillProgression.internal_name: options.SkillProgression.option_vanilla, + options.SpecialOrderLocations.internal_name: options.SpecialOrderLocations.option_vanilla, + options.ToolProgression.internal_name: options.ToolProgression.option_vanilla, + options.TrapItems.internal_name: options.TrapItems.option_nightmare, + options.Walnutsanity.internal_name: options.Walnutsanity.preset_none + } + return min_max_options + + +def minimal_locations_maximal_items_with_island(): + min_max_options = minimal_locations_maximal_items() + min_max_options.update({options.ExcludeGingerIsland.internal_name: options.ExcludeGingerIsland.option_false}) + return min_max_options diff --git a/worlds/stardew_valley/test/performance/TestPerformance.py b/worlds/stardew_valley/test/performance/TestPerformance.py index b5ad0cae66..ca63ee5e2c 100644 --- a/worlds/stardew_valley/test/performance/TestPerformance.py +++ b/worlds/stardew_valley/test/performance/TestPerformance.py @@ -8,7 +8,8 @@ from typing import List from BaseClasses import get_seed from Fill import distribute_items_restrictive, balance_multiworld_progression from worlds import AutoWorld -from .. import SVTestCase, minimal_locations_maximal_items, setup_multiworld, default_6_x_x, allsanity_no_mods_6_x_x, allsanity_mods_6_x_x +from .. import SVTestCase, setup_multiworld +from ..options.presets import default_6_x_x, allsanity_no_mods_6_x_x, allsanity_mods_6_x_x, minimal_locations_maximal_items assert default_6_x_x assert allsanity_no_mods_6_x_x diff --git a/worlds/stardew_valley/test/rules/TestStateRules.py b/worlds/stardew_valley/test/rules/TestStateRules.py index 49577d2223..db56e8220c 100644 --- a/worlds/stardew_valley/test/rules/TestStateRules.py +++ b/worlds/stardew_valley/test/rules/TestStateRules.py @@ -1,4 +1,5 @@ -from .. import SVTestBase, allsanity_mods_6_x_x +from .. import SVTestBase +from ..options.presets import allsanity_mods_6_x_x from ...stardew_rule import HasProgressionPercent diff --git a/worlds/stardew_valley/test/stability/StabilityOutputScript.py b/worlds/stardew_valley/test/stability/StabilityOutputScript.py index c8918d6cf2..a5385362b7 100644 --- a/worlds/stardew_valley/test/stability/StabilityOutputScript.py +++ b/worlds/stardew_valley/test/stability/StabilityOutputScript.py @@ -1,8 +1,9 @@ import argparse import json +from .. import setup_solo_multiworld +from ..options.presets import allsanity_mods_6_x_x from ...options import FarmType, EntranceRandomization -from ...test import setup_solo_multiworld, allsanity_mods_6_x_x if __name__ == "__main__": parser = argparse.ArgumentParser() diff --git a/worlds/stardew_valley/test/stability/TestUniversalTracker.py b/worlds/stardew_valley/test/stability/TestUniversalTracker.py index 4655b37adf..5e8075e4a1 100644 --- a/worlds/stardew_valley/test/stability/TestUniversalTracker.py +++ b/worlds/stardew_valley/test/stability/TestUniversalTracker.py @@ -1,7 +1,8 @@ import unittest from unittest.mock import Mock -from .. import SVTestBase, allsanity_mods_6_x_x, fill_namespace_with_default +from .. import SVTestBase, fill_namespace_with_default +from ..options.presets import allsanity_mods_6_x_x from ... import STARDEW_VALLEY, FarmType, BundleRandomization, EntranceRandomization From 82b0819051bebd9fcbd96501cd9b1b9df556ebe6 Mon Sep 17 00:00:00 2001 From: Fabian Dill Date: Mon, 24 Mar 2025 22:26:30 +0100 Subject: [PATCH 03/53] Core: ensure requirements files end on newline (#4761) --- test/general/test_requirements.py | 19 +++++++++++++++++++ worlds/_sc2common/requirements.txt | 2 +- worlds/alttp/requirements.txt | 2 +- worlds/kh2/requirements.txt | 2 +- worlds/minecraft/requirements.txt | 2 +- worlds/tloz/requirements.txt | 2 +- worlds/zork_grand_inquisitor/requirements.txt | 2 +- 7 files changed, 25 insertions(+), 6 deletions(-) create mode 100644 test/general/test_requirements.py diff --git a/test/general/test_requirements.py b/test/general/test_requirements.py new file mode 100644 index 0000000000..620d3d610a --- /dev/null +++ b/test/general/test_requirements.py @@ -0,0 +1,19 @@ +import unittest +import os + + +class TestBase(unittest.TestCase): + def test_requirements_file_ends_on_newline(self): + """Test that all requirements files end on a newline""" + import Utils + requirements_files = [Utils.local_path("requirements.txt"), + Utils.local_path("WebHostLib", "requirements.txt")] + worlds_path = Utils.local_path("worlds") + for entry in os.listdir(worlds_path): + requirements_path = os.path.join(worlds_path, entry, "requirements.txt") + if os.path.isfile(requirements_path): + requirements_files.append(requirements_path) + for requirements_file in requirements_files: + with self.subTest(path=requirements_file): + with open(requirements_file) as f: + self.assertEqual(f.read()[-1], "\n") diff --git a/worlds/_sc2common/requirements.txt b/worlds/_sc2common/requirements.txt index 2910b68c62..1ec405a4ab 100644 --- a/worlds/_sc2common/requirements.txt +++ b/worlds/_sc2common/requirements.txt @@ -3,4 +3,4 @@ mpyq>=0.2.5 portpicker>=1.5.2 aiohttp>=3.8.4 loguru>=0.7.0 -protobuf==3.20.3 \ No newline at end of file +protobuf==3.20.3 diff --git a/worlds/alttp/requirements.txt b/worlds/alttp/requirements.txt index f9b39c5df5..8a96da2e63 100644 --- a/worlds/alttp/requirements.txt +++ b/worlds/alttp/requirements.txt @@ -1,2 +1,2 @@ maseya-z3pr>=1.0.0rc1 -xxtea>=3.0.0 \ No newline at end of file +xxtea>=3.0.0 diff --git a/worlds/kh2/requirements.txt b/worlds/kh2/requirements.txt index 14a8bddde2..253991cda9 100644 --- a/worlds/kh2/requirements.txt +++ b/worlds/kh2/requirements.txt @@ -1 +1 @@ -Pymem>=1.10.0 \ No newline at end of file +Pymem>=1.10.0 diff --git a/worlds/minecraft/requirements.txt b/worlds/minecraft/requirements.txt index ddedb7c332..85fe230fe5 100644 --- a/worlds/minecraft/requirements.txt +++ b/worlds/minecraft/requirements.txt @@ -1 +1 @@ -requests >= 2.28.1 # used by client \ No newline at end of file +requests >= 2.28.1 # used by client diff --git a/worlds/tloz/requirements.txt b/worlds/tloz/requirements.txt index d1f50ea5e9..258d362ce9 100644 --- a/worlds/tloz/requirements.txt +++ b/worlds/tloz/requirements.txt @@ -1 +1 @@ -bsdiff4>=1.2.2 \ No newline at end of file +bsdiff4>=1.2.2 diff --git a/worlds/zork_grand_inquisitor/requirements.txt b/worlds/zork_grand_inquisitor/requirements.txt index fe25267f67..ca36764fbf 100644 --- a/worlds/zork_grand_inquisitor/requirements.txt +++ b/worlds/zork_grand_inquisitor/requirements.txt @@ -1 +1 @@ -Pymem>=1.13.0 \ No newline at end of file +Pymem>=1.13.0 From cb6b29dbe3ae3ade9734471aa680ef9752a30290 Mon Sep 17 00:00:00 2001 From: threeandthreee Date: Tue, 25 Mar 2025 17:30:25 -0400 Subject: [PATCH 04/53] LADX: fix for unconnected entrances in other worlds #4771 --- worlds/ladx/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/worlds/ladx/__init__.py b/worlds/ladx/__init__.py index ff017c144b..71c7fc6fd9 100644 --- a/worlds/ladx/__init__.py +++ b/worlds/ladx/__init__.py @@ -310,7 +310,8 @@ class LinksAwakeningWorld(World): def opens_new_regions(item): collection_state = base_collection_state.copy() - collection_state.collect(item) + collection_state.collect(item, prevent_sweep=True) + collection_state.sweep_for_advancements(self.get_locations()) return len(collection_state.reachable_regions[self.player]) > reachable_count start_items = [item for item in itempool if is_possible_start_item(item)] @@ -329,7 +330,7 @@ class LinksAwakeningWorld(World): if entrance_mapping['start_house'] not in ['start_house', 'shop']: start_items = [item for item in start_items if item.name != 'Shovel'] base_collection_state = CollectionState(self.multiworld) - base_collection_state.update_reachable_regions(self.player) + base_collection_state.sweep_for_advancements(self.get_locations()) reachable_count = len(base_collection_state.reachable_regions[self.player]) start_item = next((item for item in start_items if opens_new_regions(item)), None) From 5e5383b3990e9f52dfeb7274fa8b6e87ce7eddb5 Mon Sep 17 00:00:00 2001 From: Star Rauchenberger Date: Wed, 26 Mar 2025 20:32:39 -0400 Subject: [PATCH 05/53] Lingo: Add painting display names (#4707) * Lingo: Add painting display names * Reordered some paintings * Update generated.dat --- worlds/lingo/data/LL1.yaml | 151 +++++++++++++++++++++----- worlds/lingo/data/generated.dat | Bin 149504 -> 149504 bytes worlds/lingo/utils/validate_config.rb | 6 +- 3 files changed, 127 insertions(+), 30 deletions(-) diff --git a/worlds/lingo/data/LL1.yaml b/worlds/lingo/data/LL1.yaml index 3783b68af9..6410ffea3b 100644 --- a/worlds/lingo/data/LL1.yaml +++ b/worlds/lingo/data/LL1.yaml @@ -100,6 +100,8 @@ # paintings is an array of paintings in the room. This is used for painting # shuffling. # - id: The internal painting ID from the LINGO map. + # - display_name: The name of the painting location when showed in the + # tracker. Not needed for disabled paintings. # - enter_only: If true, painting shuffling will not place a warp exit on # this painting. # - exit_only: If true, painting shuffling will not place a warp entrance @@ -226,6 +228,7 @@ - HIDDEN paintings: - id: arrows_painting + display_name: Overhead Painting exit_only: True orientation: south - id: arrows_painting2 @@ -234,7 +237,24 @@ - id: arrows_painting3 disable: True move: True + - id: symmetry_painting_a_starter + display_name: Left Near Painting + enter_only: True + orientation: west + move: True + required_door: + room: The Wondrous (Doorknob) + door: Painting Shortcut + - id: eyes_yellow_painting2 + display_name: Left Far Painting + enter_only: True + orientation: west + move: True + required_door: + room: Outside The Agreeable + door: Painting Shortcut - id: garden_painting_tower2 + display_name: Front Left Painting enter_only: True orientation: north move: True @@ -242,20 +262,15 @@ room: Hedge Maze door: Painting Shortcut - id: flower_painting_8 + display_name: Front Right Painting enter_only: True orientation: north move: True required_door: room: Courtyard door: Painting Shortcut - - id: symmetry_painting_a_starter - enter_only: True - orientation: west - move: True - required_door: - room: The Wondrous (Doorknob) - door: Painting Shortcut - id: pencil_painting6 + display_name: Right Far Painting enter_only: True orientation: east move: True @@ -263,19 +278,13 @@ room: Outside The Bold door: Painting Shortcut - id: blueman_painting_3 + display_name: Right Near Painting enter_only: True orientation: east move: True required_door: room: Outside The Undeterred door: Painting Shortcut - - id: eyes_yellow_painting2 - enter_only: True - orientation: west - move: True - required_door: - room: Outside The Agreeable - door: Painting Shortcut Hidden Room: entrances: Starting Room: @@ -340,6 +349,7 @@ - OPEN paintings: - id: owl_painting + display_name: Painting orientation: north The Seeker: entrances: @@ -599,6 +609,7 @@ - OPEN paintings: - id: maze_painting + display_name: Near Traveled Painting orientation: west sunwarps: - dots: 1 @@ -630,6 +641,7 @@ door: Eights paintings: - id: smile_painting_6 + display_name: Painting orientation: north Sunwarps: # This is a special, meta-ish room. @@ -968,6 +980,7 @@ required_door: door: Eye Wall - id: smile_painting_4 + display_name: Near Discerning Painting orientation: south sunwarps: - dots: 1 @@ -1068,6 +1081,7 @@ tag: midwhite paintings: - id: west_afar + display_name: Painting orientation: south The Tenacious: entrances: @@ -1392,6 +1406,7 @@ - RIGHT paintings: - id: eyes_yellow_painting + display_name: Near Hallway Painting orientation: east sunwarps: - dots: 6 @@ -1451,6 +1466,7 @@ - FIRE paintings: - id: pencil_painting7 + display_name: Compass Room Painting orientation: north Dread Hallway: entrances: @@ -1698,6 +1714,7 @@ - GAZE paintings: - id: garden_painting_tower + display_name: Painting orientation: north The Fearless (First Floor): entrances: @@ -2077,6 +2094,7 @@ panel: A paintings: - id: crown_painting + display_name: Near Achievement Painting orientation: east Eight Alcove: entrances: @@ -2088,6 +2106,7 @@ door: Eight Door (Outside The Initiated) paintings: - id: eight_painting2 + display_name: Eight Alcove Painting orientation: north Eight Room: entrances: @@ -2108,6 +2127,7 @@ tag: forbid paintings: - id: eight_painting + display_name: Eight Room Painting orientation: south exit_only: True required: True @@ -2340,8 +2360,10 @@ panel: YELLOW paintings: - id: arrows_painting_6 + display_name: Left Painting orientation: east - id: flower_painting_5 + display_name: Right Painting orientation: south sunwarps: - dots: 2 @@ -2430,6 +2452,7 @@ door: Eights paintings: - id: smile_painting_8 + display_name: Hot Crusts Painting orientation: north sunwarps: - dots: 2 @@ -2531,10 +2554,13 @@ - SIZE (Big) paintings: - id: hi_solved_painting3 + display_name: Cellar Replica Painting orientation: south - id: hi_solved_painting2 + display_name: Cellar Painting orientation: south - id: east_afar + display_name: Seasons Area Painting orientation: north Orange Tower Sixth Floor: entrances: @@ -2546,25 +2572,35 @@ painting: True paintings: - id: arrows_painting_10 + display_name: Back Left Painting orientation: east - - id: owl_painting_3 - orientation: north - - id: clock_painting - orientation: west - id: scenery_painting_5d_2 + display_name: Left Near Painting orientation: south - - id: symmetry_painting_b_7 - orientation: north - id: panda_painting_2 + display_name: Left Middle Painting orientation: south - - id: crown_painting2 - orientation: north - id: colors_painting2 + display_name: Left Far Painting orientation: south - - id: cherry_painting2 - orientation: east - - id: hi_solved_painting + - id: clock_painting + display_name: Front Left Painting orientation: west + - id: hi_solved_painting + display_name: Front Right Painting + orientation: west + - id: crown_painting2 + display_name: Right Far Painting + orientation: north + - id: owl_painting_3 + display_name: Right Middle Painting + orientation: north + - id: symmetry_painting_b_7 + display_name: Right Near Painting + orientation: north + - id: cherry_painting2 + display_name: Back Right Painting + orientation: east Ending Area: entrances: Orange Tower Sixth Floor: @@ -2660,6 +2696,7 @@ panel: MASTERY paintings: - id: map_painting2 + display_name: Painting orientation: north enter_only: True # otherwise you might just skip the whole game! req_blocked_when_no_doors: True # owl hallway in vanilla doors @@ -2755,6 +2792,7 @@ non_counting: True paintings: - id: arrows_painting_11 + display_name: Painting orientation: east req_blocked_when_no_doors: True # owl hallway in vanilla doors Courtyard: @@ -2817,6 +2855,7 @@ panel: GREEN paintings: - id: flower_painting_7 + display_name: Courtyard Painting orientation: north Yellow Backside Area: entrances: @@ -2838,6 +2877,7 @@ door: Nines paintings: - id: blueman_painting + display_name: Near Nine Painting orientation: east First Second Third Fourth: # We are separating this door + its panels into its own room because they @@ -3173,6 +3213,7 @@ achievement: The Colorful paintings: - id: arrows_painting_12 + display_name: Painting orientation: north progression: Progressive Colorful: @@ -3296,13 +3337,17 @@ - STRAYS paintings: - id: arrows_painting_8 + display_name: Near Maze Painting orientation: south - id: maze_painting_2 + display_name: Maze Side Middle Painting orientation: north - id: owl_painting_2 + display_name: Orange Side Middle Painting orientation: south required_when_no_doors: True - id: clock_painting_4 + display_name: Near Orange Painting orientation: north Outside The Initiated: entrances: @@ -3490,8 +3535,10 @@ - OXEN paintings: - id: clock_painting_5 + display_name: Brown Puzzles Painting orientation: east - id: smile_painting_1 + display_name: Near Eight Painting orientation: north sunwarps: - dots: 3 @@ -3866,8 +3913,10 @@ - BEGIN paintings: - id: pencil_painting2 + display_name: Near Bold Painting orientation: west - id: north_missing2 + display_name: Directions Area Painting orientation: north The Bold: entrances: @@ -4189,12 +4238,14 @@ panel: FOUR paintings: - id: maze_painting_3 + display_name: Near Four Painting enter_only: True orientation: north move: True required_door: door: Green Painting - id: blueman_painting_2 + display_name: Near Undeterred Painting orientation: east sunwarps: - dots: 4 @@ -4557,6 +4608,7 @@ panel: NINE paintings: - id: smile_painting_5 + display_name: Near Eight Painting enter_only: True orientation: east required_door: @@ -4742,10 +4794,13 @@ - LEARN paintings: - id: smile_painting_7 + display_name: Near Turn/Return Painting orientation: south - id: flower_painting_4 + display_name: Back Area Right Painting orientation: south - id: pencil_painting3 + display_name: Back Area Left Painting enter_only: True orientation: east move: True @@ -4753,8 +4808,10 @@ room: Number Hunt door: First Six - id: boxes_painting + display_name: Near Directions Painting orientation: south - id: cherry_painting + display_name: Alcove Painting orientation: east sunwarps: - dots: 6 @@ -4848,8 +4905,10 @@ - GREEN paintings: - id: arrows_painting_7 + display_name: Near Sunwarp Painting orientation: east - id: fruitbowl_painting3 + display_name: Hidden Painting orientation: west enter_only: True required_door: @@ -4888,6 +4947,7 @@ tag: forbid paintings: - id: colors_painting + display_name: Painting orientation: south The Bearer: entrances: @@ -5369,6 +5429,7 @@ panel: ANTECHAMBER paintings: - id: pencil_painting5 + display_name: Left Painting orientation: south The Steady (Lemon): entrances: @@ -5391,6 +5452,7 @@ - MELON paintings: - id: pencil_painting4 + display_name: Right Painting orientation: south The Steady (Topaz): entrances: @@ -6012,6 +6074,7 @@ panel: NIGHT paintings: - id: smile_painting_9 + display_name: Smiley Painting orientation: north exit_only: True The Artistic (Panda): @@ -6124,6 +6187,7 @@ panel: BOWELS paintings: - id: panda_painting_3 + display_name: Panda Painting exit_only: True orientation: south required_when_no_doors: True @@ -6235,6 +6299,7 @@ panel: THING paintings: - id: boxes_painting2 + display_name: Lattice Painting orientation: south exit_only: True required_when_no_doors: True @@ -6344,6 +6409,7 @@ panel: ROOT paintings: - id: cherry_painting3 + display_name: Apple Painting orientation: north exit_only: True required_when_no_doors: True @@ -6490,8 +6556,10 @@ - NEAR paintings: - id: eye_painting_2 + display_name: Near Pillar Painting orientation: west - id: smile_painting_2 + display_name: Near Window Painting orientation: north Far Window: entrances: @@ -6512,6 +6580,7 @@ door: Exit paintings: - id: arrows_painting_5 + display_name: Lobby Painting orientation: east Outside The Wondrous: entrances: @@ -6562,9 +6631,11 @@ panel: SHRINK paintings: - id: symmetry_painting_a_1 + display_name: Doorknob Upper Painting orientation: east exit_only: True - id: symmetry_painting_b_1 + display_name: Doorknob Lower Painting orientation: south The Wondrous (Bookcase): entrances: @@ -6576,6 +6647,7 @@ tag: midblue paintings: - id: symmetry_painting_a_3 + display_name: Bookcase Painting orientation: west exit_only: True - id: symmetry_painting_b_3 @@ -6590,6 +6662,7 @@ tag: midyellow paintings: - id: symmetry_painting_a_5 + display_name: Chandelier Painting orientation: east - id: symmetry_painting_b_5 disable: True @@ -6603,6 +6676,7 @@ tag: botbrown paintings: - id: symmetry_painting_b_4 + display_name: Window Painting orientation: north exit_only: True - id: symmetry_painting_a_4 @@ -6627,8 +6701,10 @@ tag: midyellow paintings: - id: symmetry_painting_a_2 + display_name: Table Lower Painting orientation: west - id: symmetry_painting_b_2 + display_name: Table Upper Painting orientation: south exit_only: True required: True @@ -6669,6 +6745,7 @@ - Achievement paintings: - id: arrows_painting_9 + display_name: Exit Painting enter_only: True orientation: south move: True @@ -6676,9 +6753,11 @@ door: Exit req_blocked_when_no_doors: True # the wondrous (table) in vanilla doors - id: symmetry_painting_a_6 + display_name: Fireplace Upper Painting orientation: west exit_only: True - id: symmetry_painting_b_6 + display_name: Fireplace Lower Painting orientation: north req_blocked_when_no_doors: True # the wondrous (table) in vanilla doors Arrow Garden: @@ -6700,6 +6779,7 @@ tag: midwhite paintings: - id: flower_painting_6 + display_name: Painting orientation: south Hallway Room (1): entrances: @@ -6758,6 +6838,7 @@ - TOWER paintings: - id: panda_painting + display_name: Painting orientation: south progression: Progressive Hallway Room: @@ -6945,6 +7026,7 @@ tag: midwhite paintings: - id: south_afar + display_name: Painting orientation: south Outside The Wanderer: entrances: @@ -7123,16 +7205,21 @@ panels: - ORDER paintings: - - id: smile_painting_3 - orientation: west - id: flower_painting_2 + display_name: Left Near Painting orientation: east - - id: scenery_painting_0a - orientation: north - id: map_painting + display_name: Left Far Painting orientation: east - id: fruitbowl_painting4 + display_name: Center Front Painting orientation: south + - id: scenery_painting_0a + display_name: Center Back Painting + orientation: north + - id: smile_painting_3 + display_name: Right Far Painting + orientation: west progression: Progressive Art Gallery: doors: @@ -7493,6 +7580,7 @@ panel: WORD paintings: - id: arrows_painting_3 + display_name: Circle Painting orientation: north Rhyme Room (Looped Square): entrances: @@ -7675,6 +7763,7 @@ - INNOVATIVE (Bottom) paintings: - id: arrows_painting_4 + display_name: Target Painting orientation: north Room Room: # This is a bit of a weird room. You can't really get to it from the roof. @@ -7944,8 +8033,10 @@ - CAT paintings: - id: arrows_painting_2 + display_name: Left Painting orientation: east - id: clock_painting_2 + display_name: Right Painting orientation: east exit_only: True required: True @@ -8022,6 +8113,7 @@ tag: midbrown paintings: - id: clock_painting_3 + display_name: Painting orientation: east req_blocked: True # outside the wise (with or without door shuffle) The Red: @@ -8492,6 +8584,7 @@ - OPTICS paintings: - id: hi_solved_painting4 + display_name: Painting orientation: south req_blocked_when_no_doors: True # owl hallway in vanilla doors Challenge Room: diff --git a/worlds/lingo/data/generated.dat b/worlds/lingo/data/generated.dat index 646ce3b5d7430dad51aa01a3a3f0e73b62106884..14f5570db1d17d881dcefbed05323ac89b10a701 100644 GIT binary patch delta 1169 zcmZWnUrbwN6yLd}+!p9s+EQ8zas!m2vcc94=+GtALAPuQfjvwUv$Uh#ZG*i~Hqita zf6QXB+p1r)V|)?cd|I~fuxQkf>|uLavX`xeQQDa$#wXt}F{02zAI%hZo6$_K&Z8|T2zBEkR+v9E;S#(?gjj)g=wTxT~#4`f+^B4c*3 z18y;o!OJ$pW3GeB+|-5Xa6EZAp1gFymy;X1<*((nbedVL5)805LlZk_aBQ44tkuzcg_rITub%=Mrm_PN-as#vS`Fhn9k>(o=vgxQY`6 zI6?450lYAe-xr{jAYXu&3BK8ZR?j?dq>0r}#6P+$OCwYK`LtFltG~v^ZrjajZ#7ZN zd*eHMPH=o12J2UN6Fsk*W#to2yy+FJ|5V-H zR`0!0f=)C&gC?p`_*v@yS9!ez9k`^Cx1Q1PtN2S9Bs_dbsKxdoSh43hG~pP1irV0- zXx@Pyy7&4Hc=5XeMDgJcHTnc~Meq>3QiNWDcZ<*t3pif{AHf}-T)!3l_RXhCMj;I~c37E z_U$sXG^?!k+PaC;=d0vW8Jaa^^=&iy;nSg0=c@D_Qier*Ks(n~KZxB$Xy99ajS{qK z34c2&n5xUqJcpqn`F;6rq8I3oZQ)^+2PXyDBy6a=n`-MKk@?^w_7`|qE4VC$ zol@U`B<>HNlGre8VV`j&P{s~%jo@Z&HpiyXli}z|DY_tUsFtf_>vyxWv$DWOid<|2 zMD{)BG;e<&#f@X&U`t#x->8T1xQlKP3}tK{+|a@$|6hI6MkmC0xzp7u(3u0)zFsIx@=2A6?}``NoXaUPl6A7 z(;yjRmhQoCa6SzV&UluEt~l_gG!z<5YCAlzN`w7c#TDW06qsNN&!r$hxSN7z!rK(= zB0?j(>R`i2EtV}IE^bAsH3;hkd+u;1%CZrZQgQg{=TlZj&;km*}Zsi z8LYYi6*%0+8S(xynEq$Q>N40Ui(K$x=rx#gZOIVzKJucLKn^tMwS~HkMVm|U_8R2l zn-#E>PN)Whwq;{a=fU>&^`}f=Sr(jvChI>){?Yb>T{(4s7Ca@Is{bN}?%juT;$#*| z^v>GCW5)K~!OotXe3d%G1m2}Z==<;!XbDXOa$dy0zu>I7?ZlozON+7>^77<4MO1N2 m@eq_6^(-lsDuRljVo-6Yu*%oug#1wcLmp9PZgS6It?wO`gnmr` diff --git a/worlds/lingo/utils/validate_config.rb b/worlds/lingo/utils/validate_config.rb index 70f7fc2cf6..2a765fbcad 100644 --- a/worlds/lingo/utils/validate_config.rb +++ b/worlds/lingo/utils/validate_config.rb @@ -50,7 +50,7 @@ directives = Set["entrances", "panels", "doors", "panel_doors", "paintings", "su panel_directives = Set["id", "required_room", "required_door", "required_panel", "colors", "check", "exclude_reduce", "tag", "link", "subtag", "achievement", "copy_to_sign", "non_counting", "hunt", "location_name"] door_directives = Set["id", "painting_id", "panels", "item_name", "item_group", "location_name", "skip_location", "skip_item", "door_group", "include_reduce", "event", "warp_id"] panel_door_directives = Set["panels", "item_name", "panel_group"] -painting_directives = Set["id", "enter_only", "exit_only", "orientation", "required_door", "required", "required_when_no_doors", "move", "req_blocked", "req_blocked_when_no_doors"] +painting_directives = Set["id", "display_name", "enter_only", "exit_only", "orientation", "required_door", "required", "required_when_no_doors", "move", "req_blocked", "req_blocked_when_no_doors"] non_counting = 0 @@ -314,6 +314,10 @@ config.each do |room_name, room| next end + unless painting.include? "display_name" then + puts "#{room_name} - #{painting["id"] || "painting"} :::: Missing display name" + end + if painting.include?("orientation") then unless ["north", "south", "east", "west"].include? painting["orientation"] then puts "#{room_name} - #{painting["id"] || "painting"} :::: Invalid orientation #{painting["orientation"]}" From 193faa00ce1d22209e7552643f968356a16ec139 Mon Sep 17 00:00:00 2001 From: Fabian Dill Date: Fri, 28 Mar 2025 00:28:10 +0100 Subject: [PATCH 06/53] Factorio: fix energylink type back to int (#4768) --- worlds/factorio/Client.py | 9 +++++---- worlds/factorio/__init__.py | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/worlds/factorio/Client.py b/worlds/factorio/Client.py index ff1de17f0b..7aeb30cb2d 100644 --- a/worlds/factorio/Client.py +++ b/worlds/factorio/Client.py @@ -255,7 +255,8 @@ async def game_watcher(ctx: FactorioContext): if "DeathLink" in ctx.tags: async_start(ctx.send_death()) if ctx.energy_link_increment: - in_world_bridges = data["energy_bridges"] + # 1 + quality * 0.3 for each bridge + in_world_bridges: float = data["energy_bridges"] if in_world_bridges: in_world_energy = data["energy"] if in_world_energy < (ctx.energy_link_increment * in_world_bridges): @@ -263,14 +264,14 @@ async def game_watcher(ctx: FactorioContext): ctx.last_deplete = time.time() async_start(ctx.send_msgs([{ "cmd": "Set", "key": ctx.energylink_key, "operations": - [{"operation": "add", "value": -ctx.energy_link_increment * in_world_bridges}, + [{"operation": "add", "value": int(-ctx.energy_link_increment * in_world_bridges)}, {"operation": "max", "value": 0}], "last_deplete": ctx.last_deplete }])) # Above Capacity - (len(Bridges) * ENERGY_INCREMENT) elif in_world_energy > (in_world_bridges * ctx.energy_link_increment * 5) - \ ctx.energy_link_increment * in_world_bridges: - value = ctx.energy_link_increment * in_world_bridges + value = int(ctx.energy_link_increment * in_world_bridges) async_start(ctx.send_msgs([{ "cmd": "Set", "key": ctx.energylink_key, "operations": [{"operation": "add", "value": value}] @@ -406,7 +407,7 @@ async def get_info(ctx: FactorioContext, rcon_client: factorio_rcon.RCONClient): ctx.auth = info["slot_name"] ctx.seed_name = info["seed_name"] death_link = info["death_link"] - ctx.energy_link_increment = info.get("energy_link", 0) + ctx.energy_link_increment = int(info.get("energy_link", 0)) logger.debug(f"Energy Link Increment: {ctx.energy_link_increment}") if ctx.energy_link_increment and ctx.ui: ctx.ui.enable_energy_link() diff --git a/worlds/factorio/__init__.py b/worlds/factorio/__init__.py index 3f480527f5..87e36555a5 100644 --- a/worlds/factorio/__init__.py +++ b/worlds/factorio/__init__.py @@ -102,7 +102,7 @@ class Factorio(World): item_name_groups = { "Progressive": set(progressive_tech_table.keys()), } - required_client_version = (0, 5, 1) + required_client_version = (0, 6, 0) if Utils.version_tuple < required_client_version: raise Exception(f"Update Archipelago to use this world ({game}).") ordered_science_packs: typing.List[str] = MaxSciencePack.get_ordered_science_packs() From 8f75384e2ebce6adf4a4abc7c3d5c5d8e997805d Mon Sep 17 00:00:00 2001 From: PoryGone <98504756+PoryGone@users.noreply.github.com> Date: Fri, 28 Mar 2025 16:11:31 -0400 Subject: [PATCH 07/53] SA2B - v2.4 Logic Fixes (#4770) * Logic tweaks * Docs updates * Delete extra file * One more logic tweak * Add missing logic change --- worlds/sa2b/CHANGELOG.md | 2 ++ worlds/sa2b/Rules.py | 30 +++++++++++------------------- worlds/sa2b/docs/setup_en.md | 5 ++++- 3 files changed, 17 insertions(+), 20 deletions(-) diff --git a/worlds/sa2b/CHANGELOG.md b/worlds/sa2b/CHANGELOG.md index 1a4aea3eaa..1a8a238652 100644 --- a/worlds/sa2b/CHANGELOG.md +++ b/worlds/sa2b/CHANGELOG.md @@ -59,6 +59,8 @@ - Going into submenus from the pause menu should no longer reset traps - `Sonic - Magic Gloves` are now plural - Junk items will no longer cause a crash when in a falling state +- Saves should no longer incorrectly be marked as not matching the connected server +- Fixed miscellaneous crashes - Chao Garden: - Prevent races from occasionally becoming uncompletable when using the "Prize Only" option - Properly allow Hero Chao to participate in Dark Races diff --git a/worlds/sa2b/Rules.py b/worlds/sa2b/Rules.py index 583b84c55b..718c5fe045 100644 --- a/worlds/sa2b/Rules.py +++ b/worlds/sa2b/Rules.py @@ -1439,7 +1439,9 @@ def set_mission_upgrade_rules_standard(multiworld: MultiWorld, world: World, pla lambda state: state.has(ItemName.rouge_iron_boots, player)) add_rule(multiworld.get_location(LocationName.pyramid_cave_lifebox_5, player), - lambda state: state.has(ItemName.sonic_bounce_bracelet, player)) + lambda state: (state.has(ItemName.sonic_light_shoes, player) and + state.has(ItemName.sonic_bounce_bracelet, player) and + state.has(ItemName.sonic_mystic_melody, player))) add_rule(multiworld.get_location(LocationName.pyramid_cave_lifebox_6, player), lambda state: state.has(ItemName.sonic_bounce_bracelet, player)) @@ -1745,6 +1747,8 @@ def set_mission_upgrade_rules_standard(multiworld: MultiWorld, world: World, pla state.has(ItemName.sonic_bounce_bracelet, player) and state.has(ItemName.sonic_flame_ring, player))) + add_rule(multiworld.get_location(LocationName.eqq_quarters_itembox_9, player), + lambda state: state.has(ItemName.rouge_mystic_melody, player)) add_rule(multiworld.get_location(LocationName.lost_colony_itembox_9, player), lambda state: state.has(ItemName.eggman_jet_engine, player)) add_rule(multiworld.get_location(LocationName.security_hall_itembox_9, player), @@ -2464,10 +2468,6 @@ def set_mission_upgrade_rules_hard(multiworld: MultiWorld, world: World, player: add_rule(multiworld.get_location(LocationName.eternal_engine_omo_2, player), lambda state: state.has(ItemName.tails_booster, player)) - add_rule(multiworld.get_location(LocationName.weapons_bed_omo_2, player), - lambda state: state.has(ItemName.eggman_jet_engine, player) or - state.has(ItemName.eggman_large_cannon, player)) - add_rule(multiworld.get_location(LocationName.hidden_base_omo_3, player), lambda state: state.has(ItemName.tails_booster, player)) add_rule(multiworld.get_location(LocationName.eternal_engine_omo_3, player), @@ -3214,6 +3214,8 @@ def set_mission_upgrade_rules_hard(multiworld: MultiWorld, world: World, player: lambda state: (state.has(ItemName.sonic_light_shoes, player) and state.has(ItemName.sonic_flame_ring, player))) + add_rule(multiworld.get_location(LocationName.eqq_quarters_itembox_9, player), + lambda state: state.has(ItemName.rouge_mystic_melody, player)) add_rule(multiworld.get_location(LocationName.lost_colony_itembox_9, player), lambda state: state.has(ItemName.eggman_jet_engine, player)) add_rule(multiworld.get_location(LocationName.security_hall_itembox_9, player), @@ -3435,8 +3437,6 @@ def set_mission_upgrade_rules_hard(multiworld: MultiWorld, world: World, player: # Big Upgrade Requirements if world.options.bigsanity: - add_rule(multiworld.get_location(LocationName.metal_harbor_big, player), - lambda state: state.has(ItemName.sonic_bounce_bracelet, player)) add_rule(multiworld.get_location(LocationName.mission_street_big, player), lambda state: state.has(ItemName.tails_booster, player)) add_rule(multiworld.get_location(LocationName.hidden_base_big, player), @@ -3454,8 +3454,6 @@ def set_mission_upgrade_rules_hard(multiworld: MultiWorld, world: World, player: add_rule(multiworld.get_location(LocationName.lost_colony_big, player), lambda state: state.has(ItemName.eggman_jet_engine, player)) - add_rule(multiworld.get_location(LocationName.weapons_bed_big, player), - lambda state: state.has(ItemName.eggman_jet_engine, player)) add_rule(multiworld.get_location(LocationName.cannon_core_big_2, player), lambda state: state.has(ItemName.tails_booster, player)) @@ -3700,10 +3698,6 @@ def set_mission_upgrade_rules_expert(multiworld: MultiWorld, world: World, playe add_rule(multiworld.get_location(LocationName.eternal_engine_omo_2, player), lambda state: state.has(ItemName.tails_booster, player)) - add_rule(multiworld.get_location(LocationName.weapons_bed_omo_2, player), - lambda state: state.has(ItemName.eggman_jet_engine, player) or - state.has(ItemName.eggman_large_cannon, player)) - add_rule(multiworld.get_location(LocationName.hidden_base_omo_3, player), lambda state: state.has(ItemName.tails_booster, player)) add_rule(multiworld.get_location(LocationName.eternal_engine_omo_3, player), @@ -4210,7 +4204,8 @@ def set_mission_upgrade_rules_expert(multiworld: MultiWorld, world: World, playe lambda state: state.has(ItemName.eggman_jet_engine, player)) add_rule(multiworld.get_location(LocationName.eternal_engine_itembox_13, player), - lambda state: state.has(ItemName.tails_booster, player)) + lambda state: (state.has(ItemName.tails_booster, player) and + state.has(ItemName.tails_bazooka, player))) add_rule(multiworld.get_location(LocationName.mad_space_itembox_13, player), lambda state: state.has(ItemName.rouge_treasure_scope, player)) @@ -4218,7 +4213,8 @@ def set_mission_upgrade_rules_expert(multiworld: MultiWorld, world: World, playe lambda state: state.has(ItemName.eggman_jet_engine, player)) add_rule(multiworld.get_location(LocationName.eternal_engine_itembox_14, player), - lambda state: state.has(ItemName.tails_booster, player)) + lambda state: (state.has(ItemName.tails_booster, player) and + state.has(ItemName.tails_bazooka, player))) add_rule(multiworld.get_location(LocationName.cosmic_wall_itembox_14, player), lambda state: state.has(ItemName.eggman_jet_engine, player)) @@ -4264,8 +4260,6 @@ def set_mission_upgrade_rules_expert(multiworld: MultiWorld, world: World, playe # Big Upgrade Requirements if world.options.bigsanity: - add_rule(multiworld.get_location(LocationName.metal_harbor_big, player), - lambda state: state.has(ItemName.sonic_bounce_bracelet, player)) add_rule(multiworld.get_location(LocationName.mission_street_big, player), lambda state: state.has(ItemName.tails_booster, player)) add_rule(multiworld.get_location(LocationName.hidden_base_big, player), @@ -4276,8 +4270,6 @@ def set_mission_upgrade_rules_expert(multiworld: MultiWorld, world: World, playe add_rule(multiworld.get_location(LocationName.lost_colony_big, player), lambda state: state.has(ItemName.eggman_jet_engine, player)) - add_rule(multiworld.get_location(LocationName.weapons_bed_big, player), - lambda state: state.has(ItemName.eggman_jet_engine, player)) def set_boss_gate_rules(multiworld: MultiWorld, player: int, gate_bosses: typing.Dict[int, int]): diff --git a/worlds/sa2b/docs/setup_en.md b/worlds/sa2b/docs/setup_en.md index c34e45ce9b..fff504bb51 100644 --- a/worlds/sa2b/docs/setup_en.md +++ b/worlds/sa2b/docs/setup_en.md @@ -129,7 +129,10 @@ If you wish to use the `SADX Music` option of the Randomizer, you must own a cop - If you enabled an `SADX Music` option, then most likely the music data was not copied properly into the mod folder (See Additional Options for instructions). - Mission 1 is missing a texture in the stage select UI. - - Most likely another mod is conflicting and overwriting the texture pack. It is recommeded to have the SA2B Archipelago mod load last in the mod manager. + - Most likely another mod is conflicting and overwriting the texture pack. It is recommended to have the SA2B Archipelago mod load last in the mod manager. + +- Minigame trap is un-winnable + - If you are using the SA2 Input Controls mod, it conflicts with certain minigames such as the Input Sequence Trap and medium difficulty Fishing Trap. Disabling the SA2 Input Controls mod should resolve the issue. ## Save File Safeguard (Advanced Option) From 842328c661a0f11a1f03ef24c6f8443e50180f5e Mon Sep 17 00:00:00 2001 From: Scipio Wright Date: Fri, 28 Mar 2025 16:12:16 -0400 Subject: [PATCH 08/53] TUNIC: Update swamp and atoll fuse logic with weaponry (#4760) * Update swamp and atoll fuse logic with weaponry * Add it to the swamp and cath rules too --- worlds/tunic/er_rules.py | 5 ++++- worlds/tunic/rules.py | 5 +++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/worlds/tunic/er_rules.py b/worlds/tunic/er_rules.py index 7a3264b6c4..84ebb48304 100644 --- a/worlds/tunic/er_rules.py +++ b/worlds/tunic/er_rules.py @@ -689,7 +689,9 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_ atoll_statue = regions["Ruined Atoll"].connect( connecting_region=regions["Ruined Atoll Statue"], rule=lambda state: has_ability(prayer, state, world) - and (has_ladder("Ladders in South Atoll", state, world) + and ((has_ladder("Ladders in South Atoll", state, world) + and state.has_any((laurels, grapple), player) + and (has_sword(state, player) or state.has_any((fire_wand, gun), player))) # shoot fuse and have the shot hit you mid-LS or (can_ladder_storage(state, world) and state.has(fire_wand, player) and options.ladder_storage >= LadderStorage.option_hard))) @@ -1083,6 +1085,7 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_ swamp_mid_to_cath = regions["Swamp Mid"].connect( connecting_region=regions["Swamp to Cathedral Main Entrance Region"], rule=lambda state: (has_ability(prayer, state, world) + and (has_sword(state, player)) and (state.has(laurels, player) # blam yourself in the face with a wand shot off the fuse or (can_ladder_storage(state, world) and state.has(fire_wand, player) diff --git a/worlds/tunic/rules.py b/worlds/tunic/rules.py index c7b4ad0d40..2c5abb424d 100644 --- a/worlds/tunic/rules.py +++ b/worlds/tunic/rules.py @@ -125,7 +125,8 @@ def set_region_rules(world: "TunicWorld") -> None: # there's some boxes in the way and (has_melee(state, player) or state.has_any((gun, grapple, fire_wand), player))) world.get_entrance("Ruined Atoll -> Library").access_rule = \ - lambda state: state.has_any({grapple, laurels}, player) and has_ability(prayer, state, world) + lambda state: (state.has_any({grapple, laurels}, player) and has_ability(prayer, state, world) + and (has_sword(state, player) or state.has_any((fire_wand, gun), player))) world.get_entrance("Overworld -> Quarry").access_rule = \ lambda state: (has_sword(state, player) or state.has(fire_wand, player)) \ and (state.has_any({grapple, laurels, gun}, player) or can_ladder_storage(state, world)) @@ -141,7 +142,7 @@ def set_region_rules(world: "TunicWorld") -> None: world.get_entrance("Lower Quarry -> Rooted Ziggurat").access_rule = \ lambda state: state.has(grapple, player) and has_ability(prayer, state, world) world.get_entrance("Swamp -> Cathedral").access_rule = \ - lambda state: (state.has(laurels, player) and has_ability(prayer, state, world)) \ + lambda state: (state.has(laurels, player) and has_ability(prayer, state, world) and has_sword(state, player)) \ or has_ice_grapple_logic(False, IceGrappling.option_medium, state, world) world.get_entrance("Overworld -> Spirit Arena").access_rule = \ lambda state: ((state.has(gold_hexagon, player, options.hexagon_goal.value) if options.hexagon_quest.value From dd5481930a8535f2a1f18b7d8c84a29b81e95c4f Mon Sep 17 00:00:00 2001 From: Justus Lind Date: Sat, 29 Mar 2025 10:35:35 +1000 Subject: [PATCH 09/53] Muse Dash: Update docs to recommend MelonLoader 0.7.0 rather than 0.6.1 (#4776) * Tiny version update. * Update wording because there is no longer a latest button --- worlds/musedash/docs/setup_en.md | 2 +- worlds/musedash/docs/setup_es.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/worlds/musedash/docs/setup_en.md b/worlds/musedash/docs/setup_en.md index a3c2f43a91..f7a8a7e78a 100644 --- a/worlds/musedash/docs/setup_en.md +++ b/worlds/musedash/docs/setup_en.md @@ -20,7 +20,7 @@ 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. Uncheck "Latest" and select v0.6.1. Then click install. +3. Select v0.7.0. 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/` diff --git a/worlds/musedash/docs/setup_es.md b/worlds/musedash/docs/setup_es.md index fe5358921d..c53857d39a 100644 --- a/worlds/musedash/docs/setup_es.md +++ b/worlds/musedash/docs/setup_es.md @@ -20,7 +20,7 @@ 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. Desmarca "Latest" y selecciona v0.6.1. Luego haz clic en "install". +3. Selecciona v0.7.0. 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/` From c3e000e574e002dc7b22552b47682ee99cd6899e Mon Sep 17 00:00:00 2001 From: Kaito Sinclaire Date: Sat, 29 Mar 2025 09:32:33 -0700 Subject: [PATCH 10/53] id Tech 1 games: Logic updates (Feb '25) (#4677) - Across Doom 1993 and Doom 2, any items that are accessible in Ultra-Violence from the start of the level without putting the player in any danger are now considered in logic when that level is first received, without needing any weapons available. This is intended to give generation more possible outs for bad placements. - This affects the following maps in Doom 1993: - Toxin Refinery (E1M3): 1 location. - Command Control (E1M4): 1 location. - Computer Station (E1M7): 1 location. - Deimos Lab (E2M4): 1 location. - Tower of Babel (E2M8): 1 location. - Unholy Cathedral (E3M5): 1 location. - This affects the following maps in Doom 2: - The Waste Tunnels (MAP05): 2 locations. - Dead Simple (MAP07): 2 locations. - The Pit (MAP09): 1 location. - Refueling Base (MAP10): 1 location. - Nirvana (MAP21): 1 location, except see below. - Icon of Sin (MAP30): 9 locations. - Grosse (MAP32): 2 locations. - Doom 2 has had some more significant logical adjustments made. - The following Pro tricks have been added to Pro logic: - Circle of Death (MAP11): Lowering the exit wall without the Red key by hitting the switch to do so from the nukage. This makes three items previously locked behind the Red key available early, as well as the exit. - Suburbs (MAP16): Reaching the exit without any keys, as the gap between the pillar and the wall is large enough to let you through if you position yourself well. While multiple other squeeze glides exist (for example, you can skip the Yellow key in MAP21 by using one), this one is significantly easier than the rest; it does not require much precision, nor does it require vertical mouse movement. - Nirvana (MAP21): Skipping the Blue key, as there is a gigantic gap between the bars that attempt to block you. - The Chasm (MAP24): Skipping the Blue key by going extremely far through the nukage and finding one of a couple specific teleporters is now considered a Pro trick, and standard logic now expects the key to be obtained. - The following levels have had other logic adjustments: - The Waste Tunnels (MAP05): Requirements lowered to Shotgun + Super Shotgun + (Chaingun | Plasma gun). - The Crusher (MAP06): Requirements lowered to Shotgun + (Chaingun | Plasma gun) for areas immediately accessible. Going beyond the Blue key door also requires Super Shotgun. - The Factory (MAP12): The outdoors area, and the little room to the right of where you start, are accessible in sphere 1. These three items are all easily obtainable with only the pistol. The remaining items that are not in the central area are accessible with (Super Shotgun | Plasma gun), while the items in that area are accessible with Super Shotgun + Chaingun + (Plasma gun | BFG9000). This fixes Episode 2 not having an available sphere 1, and allows solo Episode 2 games. - Nirvana (MAP21): As above, the item in the starting room is accessible in sphere 1. Every other item that doesn't require a key is accessible with (Super Shotgun | Plasma gun). The room in which you use the Yellow key is accessible with Super Shotgun + Chaingun + (Plasma gun | BFG9000). This fixes Episode 3 not having an available sphere 1, and allows solo Episode 3 games. - The Catacombs (MAP22): The four items in the opening room only require (Shotgun | Super Shotgun | Plasma gun). The rest of the level is as before. - Bloodfalls (MAP25): Requirements lowered to Shotgun + Super Shotgun + Chaingun, as this level is unusually easy for its placement in the game. Progressing past the Blue key door additionally requires (Rocket launcher | Plasma gun | BFG9000) solely to deal with the Arch-vile at the end of the level. - Wolfenstein (MAP31): Requirements lowered to Chaingun + (Shotgun | Super Shotgun). This is closer to what the game expects from a non-secret hunting player from a pistol start. - The following logic bugs in Heretic have been fixed: - Quay (E5M3): An item in a Blue key locked hallway was previously marked as being in the "Main" region, thus considered to be accessible without that key. It has been moved to the appropriate "Blue" region. - Courtyard (E5M4): Logic previously assumed you could reach the Wings of Wrath from the opening room, when that isn't actually possible. Changing this moved some items previously in the "Main" region into a new "Green" region, and items previously in the "Kakis" (Yellow OR Green) are now in a "Yellow" region instead. Fixes #4662. - For known problematic solo episodes, some additional special cases have been added. - Doom 1993, Episode 3: One of either the Shotgun or Chaingun is placed early. Slough of Despair (E3M2) is given as an additional starting level. - Doom 2, Episode 3: One of either the Super Shotgun or Plasma gun is placed early. - Heretic, Episode 1: The Docks (E1M1) - Yellow key is placed early. - The following levels (and thus, their items and locations) were renamed, due to typos or other oddities: - `Barrels o Fun (MAP23)` -> `Barrels o' Fun (MAP23)` - `Wolfenstein2 (MAP31)` -> `Wolfenstein (MAP31)` - `Grosse2 (MAP32)` -> `Grosse (MAP32)` - `D'Sparil'S Keep (E3M8)` -> `D'Sparil's Keep (E3M8)` - `The Aquifier (E3M9)` -> `The Aquifer (E3M9)` --- worlds/doom_1993/Locations.py | 12 +- worlds/doom_1993/Regions.py | 53 ++++-- worlds/doom_1993/Rules.py | 64 +++---- worlds/doom_1993/__init__.py | 34 ++-- worlds/doom_ii/Items.py | 26 +-- worlds/doom_ii/Locations.py | 316 +++++++++++++++++----------------- worlds/doom_ii/Maps.py | 6 +- worlds/doom_ii/Regions.py | 158 +++++++++++++---- worlds/doom_ii/Rules.py | 209 +++++++++++----------- worlds/doom_ii/__init__.py | 26 ++- worlds/heretic/Items.py | 24 +-- worlds/heretic/Locations.py | 276 ++++++++++++++--------------- worlds/heretic/Maps.py | 4 +- worlds/heretic/Regions.py | 38 ++-- worlds/heretic/Rules.py | 40 +++-- worlds/heretic/__init__.py | 36 ++-- 16 files changed, 739 insertions(+), 583 deletions(-) diff --git a/worlds/doom_1993/Locations.py b/worlds/doom_1993/Locations.py index 90a6916cd7..5a9dd399b1 100644 --- a/worlds/doom_1993/Locations.py +++ b/worlds/doom_1993/Locations.py @@ -126,7 +126,7 @@ location_table: Dict[int, LocationDict] = { 'map': 3, 'index': 64, 'doom_type': 2001, - 'region': "Toxin Refinery (E1M3) Main"}, + 'region': "Toxin Refinery (E1M3) Start"}, 351019: {'name': 'Toxin Refinery (E1M3) - Shotgun 2', 'episode': 1, 'map': 3, @@ -234,7 +234,7 @@ location_table: Dict[int, LocationDict] = { 'map': 4, 'index': 107, 'doom_type': 8, - 'region': "Command Control (E1M4) Main"}, + 'region': "Command Control (E1M4) Start"}, 351037: {'name': 'Command Control (E1M4) - Shotgun', 'episode': 1, 'map': 4, @@ -504,7 +504,7 @@ location_table: Dict[int, LocationDict] = { 'map': 7, 'index': 122, 'doom_type': 2001, - 'region': "Computer Station (E1M7) Main"}, + 'region': "Computer Station (E1M7) Start"}, 351082: {'name': 'Computer Station (E1M7) - Rocket launcher', 'episode': 1, 'map': 7, @@ -912,7 +912,7 @@ location_table: Dict[int, LocationDict] = { 'map': 4, 'index': 109, 'doom_type': 2001, - 'region': "Deimos Lab (E2M4) Main"}, + 'region': "Deimos Lab (E2M4) Start"}, 351150: {'name': 'Deimos Lab (E2M4) - Mega Armor', 'episode': 2, 'map': 4, @@ -1242,7 +1242,7 @@ location_table: Dict[int, LocationDict] = { 'map': 8, 'index': 36, 'doom_type': 2019, - 'region': "Tower of Babel (E2M8) Main"}, + 'region': "Tower of Babel (E2M8) Start"}, 351205: {'name': 'Fortress of Mystery (E2M9) - Supercharge', 'episode': 2, 'map': 9, @@ -1638,7 +1638,7 @@ location_table: Dict[int, LocationDict] = { 'map': 5, 'index': 187, 'doom_type': 2001, - 'region': "Unholy Cathedral (E3M5) Main"}, + 'region': "Unholy Cathedral (E3M5) Start"}, 351271: {'name': 'Unholy Cathedral (E3M5) - Shotgun 2', 'episode': 3, 'map': 5, diff --git a/worlds/doom_1993/Regions.py b/worlds/doom_1993/Regions.py index c32f7b4701..b01f5a2293 100644 --- a/worlds/doom_1993/Regions.py +++ b/worlds/doom_1993/Regions.py @@ -33,9 +33,11 @@ regions:List[RegionDict] = [ # Toxin Refinery (E1M3) {"name":"Toxin Refinery (E1M3) Main", - "connects_to_hub":True, + "connects_to_hub":False, "episode":1, - "connections":[{"target":"Toxin Refinery (E1M3) Blue","pro":False}]}, + "connections":[ + {"target":"Toxin Refinery (E1M3) Blue","pro":False}, + {"target":"Toxin Refinery (E1M3) Start","pro":False}]}, {"name":"Toxin Refinery (E1M3) Blue", "connects_to_hub":False, "episode":1, @@ -46,15 +48,20 @@ regions:List[RegionDict] = [ "connects_to_hub":False, "episode":1, "connections":[{"target":"Toxin Refinery (E1M3) Blue","pro":False}]}, + {"name":"Toxin Refinery (E1M3) Start", + "connects_to_hub":True, + "episode":1, + "connections":[{"target":"Toxin Refinery (E1M3) Main","pro":False}]}, # Command Control (E1M4) {"name":"Command Control (E1M4) Main", - "connects_to_hub":True, + "connects_to_hub":False, "episode":1, "connections":[ {"target":"Command Control (E1M4) Blue","pro":False}, {"target":"Command Control (E1M4) Yellow","pro":False}, - {"target":"Command Control (E1M4) Ledge","pro":True}]}, + {"target":"Command Control (E1M4) Ledge","pro":True}, + {"target":"Command Control (E1M4) Start","pro":False}]}, {"name":"Command Control (E1M4) Blue", "connects_to_hub":False, "episode":1, @@ -72,6 +79,10 @@ regions:List[RegionDict] = [ {"target":"Command Control (E1M4) Main","pro":False}, {"target":"Command Control (E1M4) Blue","pro":False}, {"target":"Command Control (E1M4) Yellow","pro":False}]}, + {"name":"Command Control (E1M4) Start", + "connects_to_hub":True, + "episode":1, + "connections":[{"target":"Command Control (E1M4) Main","pro":False}]}, # Phobos Lab (E1M5) {"name":"Phobos Lab (E1M5) Main", @@ -126,11 +137,12 @@ regions:List[RegionDict] = [ # Computer Station (E1M7) {"name":"Computer Station (E1M7) Main", - "connects_to_hub":True, + "connects_to_hub":False, "episode":1, "connections":[ {"target":"Computer Station (E1M7) Red","pro":False}, - {"target":"Computer Station (E1M7) Yellow","pro":False}]}, + {"target":"Computer Station (E1M7) Yellow","pro":False}, + {"target":"Computer Station (E1M7) Start","pro":False}]}, {"name":"Computer Station (E1M7) Blue", "connects_to_hub":False, "episode":1, @@ -150,6 +162,10 @@ regions:List[RegionDict] = [ "connects_to_hub":False, "episode":1, "connections":[{"target":"Computer Station (E1M7) Yellow","pro":False}]}, + {"name":"Computer Station (E1M7) Start", + "connects_to_hub":True, + "episode":1, + "connections":[{"target":"Computer Station (E1M7) Main","pro":False}]}, # Phobos Anomaly (E1M8) {"name":"Phobos Anomaly (E1M8) Main", @@ -238,9 +254,11 @@ regions:List[RegionDict] = [ # Deimos Lab (E2M4) {"name":"Deimos Lab (E2M4) Main", - "connects_to_hub":True, + "connects_to_hub":False, "episode":2, - "connections":[{"target":"Deimos Lab (E2M4) Blue","pro":False}]}, + "connections":[ + {"target":"Deimos Lab (E2M4) Blue","pro":False}, + {"target":"Deimos Lab (E2M4) Start","pro":False}]}, {"name":"Deimos Lab (E2M4) Blue", "connects_to_hub":False, "episode":2, @@ -251,6 +269,10 @@ regions:List[RegionDict] = [ "connects_to_hub":False, "episode":2, "connections":[{"target":"Deimos Lab (E2M4) Blue","pro":False}]}, + {"name":"Deimos Lab (E2M4) Start", + "connects_to_hub":True, + "episode":2, + "connections":[{"target":"Deimos Lab (E2M4) Main","pro":False}]}, # Command Center (E2M5) {"name":"Command Center (E2M5) Main", @@ -314,9 +336,13 @@ regions:List[RegionDict] = [ # Tower of Babel (E2M8) {"name":"Tower of Babel (E2M8) Main", + "connects_to_hub":False, + "episode":2, + "connections":[{"target":"Tower of Babel (E2M8) Start","pro":False}]}, + {"name":"Tower of Babel (E2M8) Start", "connects_to_hub":True, "episode":2, - "connections":[]}, + "connections":[{"target":"Tower of Babel (E2M8) Main","pro":False}]}, # Fortress of Mystery (E2M9) {"name":"Fortress of Mystery (E2M9) Main", @@ -392,11 +418,12 @@ regions:List[RegionDict] = [ # Unholy Cathedral (E3M5) {"name":"Unholy Cathedral (E3M5) Main", - "connects_to_hub":True, + "connects_to_hub":False, "episode":3, "connections":[ {"target":"Unholy Cathedral (E3M5) Yellow","pro":False}, - {"target":"Unholy Cathedral (E3M5) Blue","pro":False}]}, + {"target":"Unholy Cathedral (E3M5) Blue","pro":False}, + {"target":"Unholy Cathedral (E3M5) Start","pro":False}]}, {"name":"Unholy Cathedral (E3M5) Blue", "connects_to_hub":False, "episode":3, @@ -405,6 +432,10 @@ regions:List[RegionDict] = [ "connects_to_hub":False, "episode":3, "connections":[{"target":"Unholy Cathedral (E3M5) Main","pro":False}]}, + {"name":"Unholy Cathedral (E3M5) Start", + "connects_to_hub":True, + "episode":3, + "connections":[{"target":"Unholy Cathedral (E3M5) Main","pro":False}]}, # Mt. Erebus (E3M6) {"name":"Mt. Erebus (E3M6) Main", diff --git a/worlds/doom_1993/Rules.py b/worlds/doom_1993/Rules.py index 89b09ff9f2..e113a87da0 100644 --- a/worlds/doom_1993/Rules.py +++ b/worlds/doom_1993/Rules.py @@ -23,10 +23,6 @@ def set_episode1_rules(player, multiworld, pro): state.has("Nuclear Plant (E1M2) - Red keycard", player, 1)) # Toxin Refinery (E1M3) - set_rule(multiworld.get_entrance("Hub -> Toxin Refinery (E1M3) Main", player), lambda state: - (state.has("Toxin Refinery (E1M3)", player, 1)) and - (state.has("Shotgun", player, 1) or - state.has("Chaingun", player, 1))) set_rule(multiworld.get_entrance("Toxin Refinery (E1M3) Main -> Toxin Refinery (E1M3) Blue", player), lambda state: state.has("Toxin Refinery (E1M3) - Blue keycard", player, 1)) set_rule(multiworld.get_entrance("Toxin Refinery (E1M3) Blue -> Toxin Refinery (E1M3) Yellow", player), lambda state: @@ -35,12 +31,13 @@ def set_episode1_rules(player, multiworld, pro): state.has("Toxin Refinery (E1M3) - Blue keycard", player, 1)) set_rule(multiworld.get_entrance("Toxin Refinery (E1M3) Yellow -> Toxin Refinery (E1M3) Blue", player), lambda state: state.has("Toxin Refinery (E1M3) - Yellow keycard", player, 1)) + set_rule(multiworld.get_entrance("Hub -> Toxin Refinery (E1M3) Start", player), lambda state: + state.has("Toxin Refinery (E1M3)", player, 1)) + set_rule(multiworld.get_entrance("Toxin Refinery (E1M3) Start -> Toxin Refinery (E1M3) Main", player), lambda state: + state.has("Shotgun", player, 1) or + state.has("Chaingun", player, 1)) # Command Control (E1M4) - set_rule(multiworld.get_entrance("Hub -> Command Control (E1M4) Main", player), lambda state: - state.has("Command Control (E1M4)", player, 1) and - state.has("Shotgun", player, 1) and - state.has("Chaingun", player, 1)) set_rule(multiworld.get_entrance("Command Control (E1M4) Main -> Command Control (E1M4) Blue", player), lambda state: state.has("Command Control (E1M4) - Blue keycard", player, 1) or state.has("Command Control (E1M4) - Yellow keycard", player, 1)) @@ -50,6 +47,11 @@ def set_episode1_rules(player, multiworld, pro): set_rule(multiworld.get_entrance("Command Control (E1M4) Blue -> Command Control (E1M4) Main", player), lambda state: state.has("Command Control (E1M4) - Yellow keycard", player, 1) or state.has("Command Control (E1M4) - Blue keycard", player, 1)) + set_rule(multiworld.get_entrance("Hub -> Command Control (E1M4) Start", player), lambda state: + state.has("Command Control (E1M4)", player, 1)) + set_rule(multiworld.get_entrance("Command Control (E1M4) Start -> Command Control (E1M4) Main", player), lambda state: + state.has("Shotgun", player, 1) and + state.has("Chaingun", player, 1)) # Phobos Lab (E1M5) set_rule(multiworld.get_entrance("Hub -> Phobos Lab (E1M5) Main", player), lambda state: @@ -83,11 +85,6 @@ def set_episode1_rules(player, multiworld, pro): state.has("Central Processing (E1M6) - Yellow keycard", player, 1)) # Computer Station (E1M7) - set_rule(multiworld.get_entrance("Hub -> Computer Station (E1M7) Main", player), lambda state: - state.has("Computer Station (E1M7)", player, 1) and - state.has("Shotgun", player, 1) and - state.has("Chaingun", player, 1) and - state.has("Rocket launcher", player, 1)) set_rule(multiworld.get_entrance("Computer Station (E1M7) Main -> Computer Station (E1M7) Red", player), lambda state: state.has("Computer Station (E1M7) - Red keycard", player, 1)) set_rule(multiworld.get_entrance("Computer Station (E1M7) Main -> Computer Station (E1M7) Yellow", player), lambda state: @@ -103,6 +100,12 @@ def set_episode1_rules(player, multiworld, pro): state.has("Computer Station (E1M7) - Red keycard", player, 1)) set_rule(multiworld.get_entrance("Computer Station (E1M7) Courtyard -> Computer Station (E1M7) Yellow", player), lambda state: state.has("Computer Station (E1M7) - Yellow keycard", player, 1)) + set_rule(multiworld.get_entrance("Hub -> Computer Station (E1M7) Start", player), lambda state: + state.has("Computer Station (E1M7)", player, 1)) + set_rule(multiworld.get_entrance("Computer Station (E1M7) Start -> Computer Station (E1M7) Main", player), lambda state: + state.has("Shotgun", player, 1) and + state.has("Rocket launcher", player, 1) and + state.has("Chaingun", player, 1)) # Phobos Anomaly (E1M8) set_rule(multiworld.get_entrance("Hub -> Phobos Anomaly (E1M8) Start", player), lambda state: @@ -172,15 +175,16 @@ def set_episode2_rules(player, multiworld, pro): state.has("Refinery (E2M3) - Blue keycard", player, 1)) # Deimos Lab (E2M4) - set_rule(multiworld.get_entrance("Hub -> Deimos Lab (E2M4) Main", player), lambda state: - state.has("Deimos Lab (E2M4)", player, 1) and - state.has("Shotgun", player, 1) and - state.has("Chaingun", player, 1) and - state.has("Plasma gun", player, 1)) set_rule(multiworld.get_entrance("Deimos Lab (E2M4) Main -> Deimos Lab (E2M4) Blue", player), lambda state: state.has("Deimos Lab (E2M4) - Blue keycard", player, 1)) set_rule(multiworld.get_entrance("Deimos Lab (E2M4) Blue -> Deimos Lab (E2M4) Yellow", player), lambda state: state.has("Deimos Lab (E2M4) - Yellow keycard", player, 1)) + set_rule(multiworld.get_entrance("Hub -> Deimos Lab (E2M4) Start", player), lambda state: + state.has("Deimos Lab (E2M4)", player, 1)) + set_rule(multiworld.get_entrance("Deimos Lab (E2M4) Start -> Deimos Lab (E2M4) Main", player), lambda state: + state.has("Shotgun", player, 1) and + state.has("Plasma gun", player, 1) and + state.has("Chaingun", player, 1)) # Command Center (E2M5) set_rule(multiworld.get_entrance("Hub -> Command Center (E2M5) Main", player), lambda state: @@ -238,11 +242,11 @@ def set_episode2_rules(player, multiworld, pro): state.has("Spawning Vats (E2M7) - Red keycard", player, 1)) # Tower of Babel (E2M8) - set_rule(multiworld.get_entrance("Hub -> Tower of Babel (E2M8) Main", player), lambda state: - (state.has("Tower of Babel (E2M8)", player, 1) and - state.has("Shotgun", player, 1) and - state.has("Chaingun", player, 1)) and - (state.has("Rocket launcher", player, 1) or + set_rule(multiworld.get_entrance("Hub -> Tower of Babel (E2M8) Start", player), lambda state: + state.has("Tower of Babel (E2M8)", player, 1)) + set_rule(multiworld.get_entrance("Tower of Babel (E2M8) Start -> Tower of Babel (E2M8) Main", player), lambda state: + (state.has("Chaingun", player, 1) and + state.has("Shotgun", player, 1)) and (state.has("Rocket launcher", player, 1) or state.has("Plasma gun", player, 1) or state.has("BFG9000", player, 1))) @@ -321,13 +325,6 @@ def set_episode3_rules(player, multiworld, pro): state.has("House of Pain (E3M4) - Yellow skull key", player, 1)) # Unholy Cathedral (E3M5) - set_rule(multiworld.get_entrance("Hub -> Unholy Cathedral (E3M5) Main", player), lambda state: - (state.has("Unholy Cathedral (E3M5)", player, 1) and - state.has("Chaingun", player, 1) and - state.has("Shotgun", player, 1)) and - (state.has("Rocket launcher", player, 1) or - state.has("Plasma gun", player, 1) or - state.has("BFG9000", player, 1))) set_rule(multiworld.get_entrance("Unholy Cathedral (E3M5) Main -> Unholy Cathedral (E3M5) Yellow", player), lambda state: state.has("Unholy Cathedral (E3M5) - Yellow skull key", player, 1)) set_rule(multiworld.get_entrance("Unholy Cathedral (E3M5) Main -> Unholy Cathedral (E3M5) Blue", player), lambda state: @@ -336,6 +333,13 @@ def set_episode3_rules(player, multiworld, pro): state.has("Unholy Cathedral (E3M5) - Blue skull key", player, 1)) set_rule(multiworld.get_entrance("Unholy Cathedral (E3M5) Yellow -> Unholy Cathedral (E3M5) Main", player), lambda state: state.has("Unholy Cathedral (E3M5) - Yellow skull key", player, 1)) + set_rule(multiworld.get_entrance("Hub -> Unholy Cathedral (E3M5) Start", player), lambda state: + state.has("Unholy Cathedral (E3M5)", player, 1)) + set_rule(multiworld.get_entrance("Unholy Cathedral (E3M5) Start -> Unholy Cathedral (E3M5) Main", player), lambda state: + (state.has("Chaingun", player, 1) and + state.has("Shotgun", player, 1)) and (state.has("Plasma gun", player, 1) or + state.has("Rocket launcher", player, 1) or + state.has("BFG9000", player, 1))) # Mt. Erebus (E3M6) set_rule(multiworld.get_entrance("Hub -> Mt. Erebus (E3M6) Main", player), lambda state: diff --git a/worlds/doom_1993/__init__.py b/worlds/doom_1993/__init__.py index d459290f92..d419038c98 100644 --- a/worlds/doom_1993/__init__.py +++ b/worlds/doom_1993/__init__.py @@ -50,14 +50,14 @@ class DOOM1993World(World): location_name_to_id = {data["name"]: loc_id for loc_id, data in Locations.location_table.items()} location_name_groups = Locations.location_name_groups - starting_level_for_episode: List[str] = [ - "Hangar (E1M1)", - "Deimos Anomaly (E2M1)", - "Hell Keep (E3M1)", - "Hell Beneath (E4M1)" - ] + starting_level_for_episode: Dict[int, str] = { + 1: "Hangar (E1M1)", + 2: "Deimos Anomaly (E2M1)", + 3: "Hell Keep (E3M1)", + 4: "Hell Beneath (E4M1)" + } - boss_level_for_espidoes: List[str] = [ + all_boss_levels: List[str] = [ "Phobos Anomaly (E1M8)", "Tower of Babel (E2M8)", "Dis (E3M8)", @@ -82,6 +82,7 @@ class DOOM1993World(World): def __init__(self, multiworld: MultiWorld, player: int): self.included_episodes = [1, 1, 1, 0] self.location_count = 0 + self.starting_levels = [] super().__init__(multiworld, player) @@ -99,6 +100,16 @@ class DOOM1993World(World): if self.get_episode_count() == 0: self.included_episodes[0] = 1 + self.starting_levels = [level_name for (episode, level_name) in self.starting_level_for_episode.items() + if self.included_episodes[episode - 1]] + + # Solo Episode 3 presents a problem, because Hell Keep has only two locations. + # We have to give the player Slough of Despair (E3M2), and also mark a weapon early. + if self.get_episode_count() == 1 and self.included_episodes[2]: + early_weapon = self.random.choice(["Shotgun", "Chaingun"]) + self.multiworld.early_items[self.player][early_weapon] = 1 + self.starting_levels.append("Slough of Despair (E3M2)") + def create_regions(self): pro = self.options.pro.value @@ -152,7 +163,7 @@ class DOOM1993World(World): def completion_rule(self, state: CollectionState): goal_levels = Maps.map_names if self.options.goal.value: - goal_levels = self.boss_level_for_espidoes + goal_levels = self.all_boss_levels for map_name in goal_levels: if map_name + " - Exit" not in self.location_name_to_id: @@ -201,7 +212,7 @@ class DOOM1993World(World): if item["episode"] != -1 and not self.included_episodes[item["episode"] - 1]: continue - count = item["count"] if item["name"] not in self.starting_level_for_episode else item["count"] - 1 + count = item["count"] if item["name"] not in self.starting_levels else item["count"] - 1 itempool += [self.create_item(item["name"]) for _ in range(count)] # Backpack(s) based on options @@ -232,9 +243,8 @@ class DOOM1993World(World): self.location_count -= 1 # Give starting levels right away - for i in range(len(self.included_episodes)): - if self.included_episodes[i]: - self.multiworld.push_precollected(self.create_item(self.starting_level_for_episode[i])) + for map_name in self.starting_levels: + self.multiworld.push_precollected(self.create_item(map_name)) # Give Computer area maps if option selected if self.options.start_with_computer_area_maps.value: diff --git a/worlds/doom_ii/Items.py b/worlds/doom_ii/Items.py index 009e6034cd..b6a72c9a6b 100644 --- a/worlds/doom_ii/Items.py +++ b/worlds/doom_ii/Items.py @@ -412,7 +412,7 @@ item_table: Dict[int, ItemDict] = { 'map': 2}, 360246: {'classification': ItemClassification.progression, 'count': 1, - 'name': 'Barrels o Fun (MAP23) - Yellow skull key', + 'name': "Barrels o' Fun (MAP23) - Yellow skull key", 'doom_type': 39, 'episode': 3, 'map': 3}, @@ -880,19 +880,19 @@ item_table: Dict[int, ItemDict] = { 'map': 2}, 360466: {'classification': ItemClassification.progression, 'count': 1, - 'name': 'Barrels o Fun (MAP23)', + 'name': "Barrels o' Fun (MAP23)", 'doom_type': -1, 'episode': 3, 'map': 3}, 360467: {'classification': ItemClassification.progression, 'count': 1, - 'name': 'Barrels o Fun (MAP23) - Complete', + 'name': "Barrels o' Fun (MAP23) - Complete", 'doom_type': -2, 'episode': 3, 'map': 3}, 360468: {'classification': ItemClassification.filler, 'count': 1, - 'name': 'Barrels o Fun (MAP23) - Computer area map', + 'name': "Barrels o' Fun (MAP23) - Computer area map", 'doom_type': 2026, 'episode': 3, 'map': 3}, @@ -1024,37 +1024,37 @@ item_table: Dict[int, ItemDict] = { 'map': 10}, 360490: {'classification': ItemClassification.progression, 'count': 1, - 'name': 'Wolfenstein2 (MAP31)', + 'name': 'Wolfenstein (MAP31)', 'doom_type': -1, 'episode': 4, 'map': 1}, 360491: {'classification': ItemClassification.progression, 'count': 1, - 'name': 'Wolfenstein2 (MAP31) - Complete', + 'name': 'Wolfenstein (MAP31) - Complete', 'doom_type': -2, 'episode': 4, 'map': 1}, 360492: {'classification': ItemClassification.filler, 'count': 1, - 'name': 'Wolfenstein2 (MAP31) - Computer area map', + 'name': 'Wolfenstein (MAP31) - Computer area map', 'doom_type': 2026, 'episode': 4, 'map': 1}, 360493: {'classification': ItemClassification.progression, 'count': 1, - 'name': 'Grosse2 (MAP32)', + 'name': 'Grosse (MAP32)', 'doom_type': -1, 'episode': 4, 'map': 2}, 360494: {'classification': ItemClassification.progression, 'count': 1, - 'name': 'Grosse2 (MAP32) - Complete', + 'name': 'Grosse (MAP32) - Complete', 'doom_type': -2, 'episode': 4, 'map': 2}, 360495: {'classification': ItemClassification.filler, 'count': 1, - 'name': 'Grosse2 (MAP32) - Computer area map', + 'name': 'Grosse (MAP32) - Computer area map', 'doom_type': 2026, 'episode': 4, 'map': 2}, @@ -1087,9 +1087,9 @@ item_table: Dict[int, ItemDict] = { item_name_groups: Dict[str, Set[str]] = { 'Ammos': {'Box of bullets', 'Box of rockets', 'Box of shotgun shells', 'Energy cell pack', }, - 'Computer area maps': {'Barrels o Fun (MAP23) - Computer area map', 'Bloodfalls (MAP25) - Computer area map', 'Circle of Death (MAP11) - Computer area map', 'Dead Simple (MAP07) - Computer area map', 'Downtown (MAP13) - Computer area map', 'Entryway (MAP01) - Computer area map', 'Gotcha! (MAP20) - Computer area map', 'Grosse2 (MAP32) - Computer area map', 'Icon of Sin (MAP30) - Computer area map', 'Industrial Zone (MAP15) - Computer area map', 'Monster Condo (MAP27) - Computer area map', 'Nirvana (MAP21) - Computer area map', 'Refueling Base (MAP10) - Computer area map', 'Suburbs (MAP16) - Computer area map', 'Tenements (MAP17) - Computer area map', 'The Abandoned Mines (MAP26) - Computer area map', 'The Catacombs (MAP22) - Computer area map', 'The Chasm (MAP24) - Computer area map', 'The Citadel (MAP19) - Computer area map', 'The Courtyard (MAP18) - Computer area map', 'The Crusher (MAP06) - Computer area map', 'The Factory (MAP12) - Computer area map', 'The Focus (MAP04) - Computer area map', 'The Gantlet (MAP03) - Computer area map', 'The Inmost Dens (MAP14) - Computer area map', 'The Living End (MAP29) - Computer area map', 'The Pit (MAP09) - Computer area map', 'The Spirit World (MAP28) - Computer area map', 'The Waste Tunnels (MAP05) - Computer area map', 'Tricks and Traps (MAP08) - Computer area map', 'Underhalls (MAP02) - Computer area map', 'Wolfenstein2 (MAP31) - Computer area map', }, - 'Keys': {'Barrels o Fun (MAP23) - Yellow skull key', 'Bloodfalls (MAP25) - Blue skull key', 'Circle of Death (MAP11) - Blue keycard', 'Circle of Death (MAP11) - Red keycard', 'Downtown (MAP13) - Blue keycard', 'Downtown (MAP13) - Red keycard', 'Downtown (MAP13) - Yellow keycard', 'Industrial Zone (MAP15) - Blue keycard', 'Industrial Zone (MAP15) - Red keycard', 'Industrial Zone (MAP15) - Yellow keycard', 'Monster Condo (MAP27) - Blue skull key', 'Monster Condo (MAP27) - Red skull key', 'Monster Condo (MAP27) - Yellow skull key', 'Nirvana (MAP21) - Blue skull key', 'Nirvana (MAP21) - Red skull key', 'Nirvana (MAP21) - Yellow skull key', 'Refueling Base (MAP10) - Blue keycard', 'Refueling Base (MAP10) - Yellow keycard', 'Suburbs (MAP16) - Blue skull key', 'Suburbs (MAP16) - Red skull key', 'Tenements (MAP17) - Blue keycard', 'Tenements (MAP17) - Red keycard', 'Tenements (MAP17) - Yellow skull key', 'The Abandoned Mines (MAP26) - Blue keycard', 'The Abandoned Mines (MAP26) - Red keycard', 'The Abandoned Mines (MAP26) - Yellow keycard', 'The Catacombs (MAP22) - Blue skull key', 'The Catacombs (MAP22) - Red skull key', 'The Chasm (MAP24) - Blue keycard', 'The Chasm (MAP24) - Red keycard', 'The Citadel (MAP19) - Blue skull key', 'The Citadel (MAP19) - Red skull key', 'The Citadel (MAP19) - Yellow skull key', 'The Courtyard (MAP18) - Blue skull key', 'The Courtyard (MAP18) - Yellow skull key', 'The Crusher (MAP06) - Blue keycard', 'The Crusher (MAP06) - Red keycard', 'The Crusher (MAP06) - Yellow keycard', 'The Factory (MAP12) - Blue keycard', 'The Factory (MAP12) - Yellow keycard', 'The Focus (MAP04) - Blue keycard', 'The Focus (MAP04) - Red keycard', 'The Focus (MAP04) - Yellow keycard', 'The Gantlet (MAP03) - Blue keycard', 'The Gantlet (MAP03) - Red keycard', 'The Inmost Dens (MAP14) - Blue skull key', 'The Inmost Dens (MAP14) - Red skull key', 'The Pit (MAP09) - Blue keycard', 'The Pit (MAP09) - Yellow keycard', 'The Spirit World (MAP28) - Red skull key', 'The Spirit World (MAP28) - Yellow skull key', 'The Waste Tunnels (MAP05) - Blue keycard', 'The Waste Tunnels (MAP05) - Red keycard', 'The Waste Tunnels (MAP05) - Yellow keycard', 'Tricks and Traps (MAP08) - Red skull key', 'Tricks and Traps (MAP08) - Yellow skull key', 'Underhalls (MAP02) - Blue keycard', 'Underhalls (MAP02) - Red keycard', }, - 'Levels': {'Barrels o Fun (MAP23)', 'Bloodfalls (MAP25)', 'Circle of Death (MAP11)', 'Dead Simple (MAP07)', 'Downtown (MAP13)', 'Entryway (MAP01)', 'Gotcha! (MAP20)', 'Grosse2 (MAP32)', 'Icon of Sin (MAP30)', 'Industrial Zone (MAP15)', 'Monster Condo (MAP27)', 'Nirvana (MAP21)', 'Refueling Base (MAP10)', 'Suburbs (MAP16)', 'Tenements (MAP17)', 'The Abandoned Mines (MAP26)', 'The Catacombs (MAP22)', 'The Chasm (MAP24)', 'The Citadel (MAP19)', 'The Courtyard (MAP18)', 'The Crusher (MAP06)', 'The Factory (MAP12)', 'The Focus (MAP04)', 'The Gantlet (MAP03)', 'The Inmost Dens (MAP14)', 'The Living End (MAP29)', 'The Pit (MAP09)', 'The Spirit World (MAP28)', 'The Waste Tunnels (MAP05)', 'Tricks and Traps (MAP08)', 'Underhalls (MAP02)', 'Wolfenstein2 (MAP31)', }, + 'Computer area maps': {"Barrels o' Fun (MAP23) - Computer area map", 'Bloodfalls (MAP25) - Computer area map', 'Circle of Death (MAP11) - Computer area map', 'Dead Simple (MAP07) - Computer area map', 'Downtown (MAP13) - Computer area map', 'Entryway (MAP01) - Computer area map', 'Gotcha! (MAP20) - Computer area map', 'Grosse (MAP32) - Computer area map', 'Icon of Sin (MAP30) - Computer area map', 'Industrial Zone (MAP15) - Computer area map', 'Monster Condo (MAP27) - Computer area map', 'Nirvana (MAP21) - Computer area map', 'Refueling Base (MAP10) - Computer area map', 'Suburbs (MAP16) - Computer area map', 'Tenements (MAP17) - Computer area map', 'The Abandoned Mines (MAP26) - Computer area map', 'The Catacombs (MAP22) - Computer area map', 'The Chasm (MAP24) - Computer area map', 'The Citadel (MAP19) - Computer area map', 'The Courtyard (MAP18) - Computer area map', 'The Crusher (MAP06) - Computer area map', 'The Factory (MAP12) - Computer area map', 'The Focus (MAP04) - Computer area map', 'The Gantlet (MAP03) - Computer area map', 'The Inmost Dens (MAP14) - Computer area map', 'The Living End (MAP29) - Computer area map', 'The Pit (MAP09) - Computer area map', 'The Spirit World (MAP28) - Computer area map', 'The Waste Tunnels (MAP05) - Computer area map', 'Tricks and Traps (MAP08) - Computer area map', 'Underhalls (MAP02) - Computer area map', 'Wolfenstein (MAP31) - Computer area map', }, + 'Keys': {"Barrels o' Fun (MAP23) - Yellow skull key", 'Bloodfalls (MAP25) - Blue skull key', 'Circle of Death (MAP11) - Blue keycard', 'Circle of Death (MAP11) - Red keycard', 'Downtown (MAP13) - Blue keycard', 'Downtown (MAP13) - Red keycard', 'Downtown (MAP13) - Yellow keycard', 'Industrial Zone (MAP15) - Blue keycard', 'Industrial Zone (MAP15) - Red keycard', 'Industrial Zone (MAP15) - Yellow keycard', 'Monster Condo (MAP27) - Blue skull key', 'Monster Condo (MAP27) - Red skull key', 'Monster Condo (MAP27) - Yellow skull key', 'Nirvana (MAP21) - Blue skull key', 'Nirvana (MAP21) - Red skull key', 'Nirvana (MAP21) - Yellow skull key', 'Refueling Base (MAP10) - Blue keycard', 'Refueling Base (MAP10) - Yellow keycard', 'Suburbs (MAP16) - Blue skull key', 'Suburbs (MAP16) - Red skull key', 'Tenements (MAP17) - Blue keycard', 'Tenements (MAP17) - Red keycard', 'Tenements (MAP17) - Yellow skull key', 'The Abandoned Mines (MAP26) - Blue keycard', 'The Abandoned Mines (MAP26) - Red keycard', 'The Abandoned Mines (MAP26) - Yellow keycard', 'The Catacombs (MAP22) - Blue skull key', 'The Catacombs (MAP22) - Red skull key', 'The Chasm (MAP24) - Blue keycard', 'The Chasm (MAP24) - Red keycard', 'The Citadel (MAP19) - Blue skull key', 'The Citadel (MAP19) - Red skull key', 'The Citadel (MAP19) - Yellow skull key', 'The Courtyard (MAP18) - Blue skull key', 'The Courtyard (MAP18) - Yellow skull key', 'The Crusher (MAP06) - Blue keycard', 'The Crusher (MAP06) - Red keycard', 'The Crusher (MAP06) - Yellow keycard', 'The Factory (MAP12) - Blue keycard', 'The Factory (MAP12) - Yellow keycard', 'The Focus (MAP04) - Blue keycard', 'The Focus (MAP04) - Red keycard', 'The Focus (MAP04) - Yellow keycard', 'The Gantlet (MAP03) - Blue keycard', 'The Gantlet (MAP03) - Red keycard', 'The Inmost Dens (MAP14) - Blue skull key', 'The Inmost Dens (MAP14) - Red skull key', 'The Pit (MAP09) - Blue keycard', 'The Pit (MAP09) - Yellow keycard', 'The Spirit World (MAP28) - Red skull key', 'The Spirit World (MAP28) - Yellow skull key', 'The Waste Tunnels (MAP05) - Blue keycard', 'The Waste Tunnels (MAP05) - Red keycard', 'The Waste Tunnels (MAP05) - Yellow keycard', 'Tricks and Traps (MAP08) - Red skull key', 'Tricks and Traps (MAP08) - Yellow skull key', 'Underhalls (MAP02) - Blue keycard', 'Underhalls (MAP02) - Red keycard', }, + 'Levels': {"Barrels o' Fun (MAP23)", 'Bloodfalls (MAP25)', 'Circle of Death (MAP11)', 'Dead Simple (MAP07)', 'Downtown (MAP13)', 'Entryway (MAP01)', 'Gotcha! (MAP20)', 'Grosse (MAP32)', 'Icon of Sin (MAP30)', 'Industrial Zone (MAP15)', 'Monster Condo (MAP27)', 'Nirvana (MAP21)', 'Refueling Base (MAP10)', 'Suburbs (MAP16)', 'Tenements (MAP17)', 'The Abandoned Mines (MAP26)', 'The Catacombs (MAP22)', 'The Chasm (MAP24)', 'The Citadel (MAP19)', 'The Courtyard (MAP18)', 'The Crusher (MAP06)', 'The Factory (MAP12)', 'The Focus (MAP04)', 'The Gantlet (MAP03)', 'The Inmost Dens (MAP14)', 'The Living End (MAP29)', 'The Pit (MAP09)', 'The Spirit World (MAP28)', 'The Waste Tunnels (MAP05)', 'Tricks and Traps (MAP08)', 'Underhalls (MAP02)', 'Wolfenstein (MAP31)', }, 'Powerups': {'Armor', 'Berserk', 'Invulnerability', 'Mega Armor', 'Megasphere', 'Partial invisibility', 'Supercharge', }, 'Weapons': {'BFG9000', 'Chaingun', 'Chainsaw', 'Plasma gun', 'Rocket launcher', 'Shotgun', 'Super Shotgun', }, } diff --git a/worlds/doom_ii/Locations.py b/worlds/doom_ii/Locations.py index 376f19446f..7aa2311b76 100644 --- a/worlds/doom_ii/Locations.py +++ b/worlds/doom_ii/Locations.py @@ -180,7 +180,7 @@ location_table: Dict[int, LocationDict] = { 'map': 5, 'index': 46, 'doom_type': 82, - 'region': "The Waste Tunnels (MAP05) Main"}, + 'region': "The Waste Tunnels (MAP05) Start"}, 361028: {'name': 'The Waste Tunnels (MAP05) - Blue keycard', 'episode': 1, 'map': 5, @@ -234,7 +234,7 @@ location_table: Dict[int, LocationDict] = { 'map': 5, 'index': 202, 'doom_type': 2001, - 'region': "The Waste Tunnels (MAP05) Main"}, + 'region': "The Waste Tunnels (MAP05) Start"}, 361037: {'name': 'The Waste Tunnels (MAP05) - Berserk', 'episode': 1, 'map': 5, @@ -360,7 +360,7 @@ location_table: Dict[int, LocationDict] = { 'map': 7, 'index': 8, 'doom_type': 82, - 'region': "Dead Simple (MAP07) Main"}, + 'region': "Dead Simple (MAP07) Start"}, 361058: {'name': 'Dead Simple (MAP07) - Chaingun', 'episode': 1, 'map': 7, @@ -378,7 +378,7 @@ location_table: Dict[int, LocationDict] = { 'map': 7, 'index': 43, 'doom_type': 8, - 'region': "Dead Simple (MAP07) Main"}, + 'region': "Dead Simple (MAP07) Start"}, 361061: {'name': 'Dead Simple (MAP07) - Berserk', 'episode': 1, 'map': 7, @@ -570,7 +570,7 @@ location_table: Dict[int, LocationDict] = { 'map': 9, 'index': 26, 'doom_type': 2019, - 'region': "The Pit (MAP09) Main"}, + 'region': "The Pit (MAP09) Start"}, 361093: {'name': 'The Pit (MAP09) - Supercharge', 'episode': 1, 'map': 9, @@ -678,7 +678,7 @@ location_table: Dict[int, LocationDict] = { 'map': 10, 'index': 99, 'doom_type': 2001, - 'region': "Refueling Base (MAP10) Main"}, + 'region': "Refueling Base (MAP10) Start"}, 361111: {'name': 'Refueling Base (MAP10) - Chaingun', 'episode': 1, 'map': 10, @@ -846,31 +846,31 @@ location_table: Dict[int, LocationDict] = { 'map': 11, 'index': 88, 'doom_type': 8, - 'region': "Circle of Death (MAP11) Red"}, + 'region': "Circle of Death (MAP11) Ending"}, 361139: {'name': 'Circle of Death (MAP11) - Supercharge 2', 'episode': 1, 'map': 11, 'index': 108, 'doom_type': 2013, - 'region': "Circle of Death (MAP11) Red"}, + 'region': "Circle of Death (MAP11) Ending"}, 361140: {'name': 'Circle of Death (MAP11) - BFG9000', 'episode': 1, 'map': 11, 'index': 110, 'doom_type': 2006, - 'region': "Circle of Death (MAP11) Red"}, + 'region': "Circle of Death (MAP11) Ending"}, 361141: {'name': 'Circle of Death (MAP11) - Exit', 'episode': 1, 'map': 11, 'index': -1, 'doom_type': -1, - 'region': "Circle of Death (MAP11) Red"}, + 'region': "Circle of Death (MAP11) Ending"}, 361142: {'name': 'The Factory (MAP12) - Shotgun', 'episode': 2, 'map': 1, 'index': 14, 'doom_type': 2001, - 'region': "The Factory (MAP12) Main"}, + 'region': "The Factory (MAP12) Outdoors"}, 361143: {'name': 'The Factory (MAP12) - Berserk', 'episode': 2, 'map': 1, @@ -888,13 +888,13 @@ location_table: Dict[int, LocationDict] = { 'map': 1, 'index': 52, 'doom_type': 2013, - 'region': "The Factory (MAP12) Main"}, + 'region': "The Factory (MAP12) Indoors"}, 361146: {'name': 'The Factory (MAP12) - Blue keycard', 'episode': 2, 'map': 1, 'index': 54, 'doom_type': 5, - 'region': "The Factory (MAP12) Main"}, + 'region': "The Factory (MAP12) Indoors"}, 361147: {'name': 'The Factory (MAP12) - Armor', 'episode': 2, 'map': 1, @@ -912,31 +912,31 @@ location_table: Dict[int, LocationDict] = { 'map': 1, 'index': 83, 'doom_type': 2013, - 'region': "The Factory (MAP12) Main"}, + 'region': "The Factory (MAP12) Indoors"}, 361150: {'name': 'The Factory (MAP12) - Armor 2', 'episode': 2, 'map': 1, 'index': 92, 'doom_type': 2018, - 'region': "The Factory (MAP12) Main"}, + 'region': "The Factory (MAP12) Outdoors"}, 361151: {'name': 'The Factory (MAP12) - Partial invisibility', 'episode': 2, 'map': 1, 'index': 93, 'doom_type': 2024, - 'region': "The Factory (MAP12) Main"}, + 'region': "The Factory (MAP12) Outdoors"}, 361152: {'name': 'The Factory (MAP12) - Berserk 2', 'episode': 2, 'map': 1, 'index': 107, 'doom_type': 2023, - 'region': "The Factory (MAP12) Main"}, + 'region': "The Factory (MAP12) Indoors"}, 361153: {'name': 'The Factory (MAP12) - Yellow keycard', 'episode': 2, 'map': 1, 'index': 123, 'doom_type': 6, - 'region': "The Factory (MAP12) Main"}, + 'region': "The Factory (MAP12) Indoors"}, 361154: {'name': 'The Factory (MAP12) - BFG9000', 'episode': 2, 'map': 1, @@ -954,7 +954,7 @@ location_table: Dict[int, LocationDict] = { 'map': 1, 'index': 192, 'doom_type': 82, - 'region': "The Factory (MAP12) Main"}, + 'region': "The Factory (MAP12) Indoors"}, 361157: {'name': 'The Factory (MAP12) - Exit', 'episode': 2, 'map': 1, @@ -1812,7 +1812,7 @@ location_table: Dict[int, LocationDict] = { 'map': 1, 'index': 70, 'doom_type': 82, - 'region': "Nirvana (MAP21) Main"}, + 'region': "Nirvana (MAP21) Start"}, 361300: {'name': 'Nirvana (MAP21) - Rocket launcher', 'episode': 3, 'map': 1, @@ -1884,7 +1884,7 @@ location_table: Dict[int, LocationDict] = { 'map': 2, 'index': 28, 'doom_type': 2001, - 'region': "The Catacombs (MAP22) Main"}, + 'region': "The Catacombs (MAP22) Early"}, 361312: {'name': 'The Catacombs (MAP22) - Berserk', 'episode': 3, 'map': 2, @@ -1896,103 +1896,103 @@ location_table: Dict[int, LocationDict] = { 'map': 2, 'index': 83, 'doom_type': 2004, - 'region': "The Catacombs (MAP22) Main"}, + 'region': "The Catacombs (MAP22) Early"}, 361314: {'name': 'The Catacombs (MAP22) - Supercharge', 'episode': 3, 'map': 2, 'index': 118, 'doom_type': 2013, - 'region': "The Catacombs (MAP22) Main"}, + 'region': "The Catacombs (MAP22) Early"}, 361315: {'name': 'The Catacombs (MAP22) - Armor', 'episode': 3, 'map': 2, 'index': 119, 'doom_type': 2018, - 'region': "The Catacombs (MAP22) Main"}, + 'region': "The Catacombs (MAP22) Early"}, 361316: {'name': 'The Catacombs (MAP22) - Exit', 'episode': 3, 'map': 2, 'index': -1, 'doom_type': -1, 'region': "The Catacombs (MAP22) Red"}, - 361317: {'name': 'Barrels o Fun (MAP23) - Shotgun', + 361317: {'name': "Barrels o' Fun (MAP23) - Shotgun", 'episode': 3, 'map': 3, 'index': 136, 'doom_type': 2001, - 'region': "Barrels o Fun (MAP23) Main"}, - 361318: {'name': 'Barrels o Fun (MAP23) - Berserk', + 'region': "Barrels o' Fun (MAP23) Main"}, + 361318: {'name': "Barrels o' Fun (MAP23) - Berserk", 'episode': 3, 'map': 3, 'index': 222, 'doom_type': 2023, - 'region': "Barrels o Fun (MAP23) Main"}, - 361319: {'name': 'Barrels o Fun (MAP23) - Backpack', + 'region': "Barrels o' Fun (MAP23) Main"}, + 361319: {'name': "Barrels o' Fun (MAP23) - Backpack", 'episode': 3, 'map': 3, 'index': 223, 'doom_type': 8, - 'region': "Barrels o Fun (MAP23) Main"}, - 361320: {'name': 'Barrels o Fun (MAP23) - Computer area map', + 'region': "Barrels o' Fun (MAP23) Main"}, + 361320: {'name': "Barrels o' Fun (MAP23) - Computer area map", 'episode': 3, 'map': 3, 'index': 224, 'doom_type': 2026, - 'region': "Barrels o Fun (MAP23) Main"}, - 361321: {'name': 'Barrels o Fun (MAP23) - Armor', + 'region': "Barrels o' Fun (MAP23) Main"}, + 361321: {'name': "Barrels o' Fun (MAP23) - Armor", 'episode': 3, 'map': 3, 'index': 249, 'doom_type': 2018, - 'region': "Barrels o Fun (MAP23) Main"}, - 361322: {'name': 'Barrels o Fun (MAP23) - Rocket launcher', + 'region': "Barrels o' Fun (MAP23) Main"}, + 361322: {'name': "Barrels o' Fun (MAP23) - Rocket launcher", 'episode': 3, 'map': 3, 'index': 264, 'doom_type': 2003, - 'region': "Barrels o Fun (MAP23) Main"}, - 361323: {'name': 'Barrels o Fun (MAP23) - Megasphere', + 'region': "Barrels o' Fun (MAP23) Main"}, + 361323: {'name': "Barrels o' Fun (MAP23) - Megasphere", 'episode': 3, 'map': 3, 'index': 266, 'doom_type': 83, - 'region': "Barrels o Fun (MAP23) Main"}, - 361324: {'name': 'Barrels o Fun (MAP23) - Supercharge', + 'region': "Barrels o' Fun (MAP23) Main"}, + 361324: {'name': "Barrels o' Fun (MAP23) - Supercharge", 'episode': 3, 'map': 3, 'index': 277, 'doom_type': 2013, - 'region': "Barrels o Fun (MAP23) Main"}, - 361325: {'name': 'Barrels o Fun (MAP23) - Backpack 2', + 'region': "Barrels o' Fun (MAP23) Main"}, + 361325: {'name': "Barrels o' Fun (MAP23) - Backpack 2", 'episode': 3, 'map': 3, 'index': 301, 'doom_type': 8, - 'region': "Barrels o Fun (MAP23) Main"}, - 361326: {'name': 'Barrels o Fun (MAP23) - Yellow skull key', + 'region': "Barrels o' Fun (MAP23) Main"}, + 361326: {'name': "Barrels o' Fun (MAP23) - Yellow skull key", 'episode': 3, 'map': 3, 'index': 307, 'doom_type': 39, - 'region': "Barrels o Fun (MAP23) Main"}, - 361327: {'name': 'Barrels o Fun (MAP23) - BFG9000', + 'region': "Barrels o' Fun (MAP23) Main"}, + 361327: {'name': "Barrels o' Fun (MAP23) - BFG9000", 'episode': 3, 'map': 3, 'index': 342, 'doom_type': 2006, - 'region': "Barrels o Fun (MAP23) Main"}, - 361328: {'name': 'Barrels o Fun (MAP23) - Exit', + 'region': "Barrels o' Fun (MAP23) Main"}, + 361328: {'name': "Barrels o' Fun (MAP23) - Exit", 'episode': 3, 'map': 3, 'index': -1, 'doom_type': -1, - 'region': "Barrels o Fun (MAP23) Yellow"}, + 'region': "Barrels o' Fun (MAP23) Yellow"}, 361329: {'name': 'The Chasm (MAP24) - Plasma gun', 'episode': 3, 'map': 4, 'index': 5, 'doom_type': 2004, - 'region': "The Chasm (MAP24) Main"}, + 'region': "The Chasm (MAP24) Blue"}, 361330: {'name': 'The Chasm (MAP24) - Shotgun', 'episode': 3, 'map': 4, @@ -2004,7 +2004,7 @@ location_table: Dict[int, LocationDict] = { 'map': 4, 'index': 12, 'doom_type': 2022, - 'region': "The Chasm (MAP24) Main"}, + 'region': "The Chasm (MAP24) Blue"}, 361332: {'name': 'The Chasm (MAP24) - Rocket launcher', 'episode': 3, 'map': 4, @@ -2022,7 +2022,7 @@ location_table: Dict[int, LocationDict] = { 'map': 4, 'index': 31, 'doom_type': 8, - 'region': "The Chasm (MAP24) Main"}, + 'region': "The Chasm (MAP24) Blue"}, 361335: {'name': 'The Chasm (MAP24) - Berserk', 'episode': 3, 'map': 4, @@ -2034,19 +2034,19 @@ location_table: Dict[int, LocationDict] = { 'map': 4, 'index': 155, 'doom_type': 2023, - 'region': "The Chasm (MAP24) Main"}, + 'region': "The Chasm (MAP24) Blue"}, 361337: {'name': 'The Chasm (MAP24) - Armor', 'episode': 3, 'map': 4, 'index': 169, 'doom_type': 2018, - 'region': "The Chasm (MAP24) Main"}, + 'region': "The Chasm (MAP24) Blue"}, 361338: {'name': 'The Chasm (MAP24) - Red keycard', 'episode': 3, 'map': 4, 'index': 261, 'doom_type': 13, - 'region': "The Chasm (MAP24) Main"}, + 'region': "The Chasm (MAP24) Blue"}, 361339: {'name': 'The Chasm (MAP24) - BFG9000', 'episode': 3, 'map': 4, @@ -2064,7 +2064,7 @@ location_table: Dict[int, LocationDict] = { 'map': 4, 'index': 355, 'doom_type': 83, - 'region': "The Chasm (MAP24) Main"}, + 'region': "The Chasm (MAP24) Blue"}, 361342: {'name': 'The Chasm (MAP24) - Megasphere 2', 'episode': 3, 'map': 4, @@ -2082,7 +2082,7 @@ location_table: Dict[int, LocationDict] = { 'map': 5, 'index': 6, 'doom_type': 82, - 'region': "Bloodfalls (MAP25) Main"}, + 'region': "Bloodfalls (MAP25) Start"}, 361345: {'name': 'Bloodfalls (MAP25) - Partial invisibility', 'episode': 3, 'map': 5, @@ -2664,55 +2664,55 @@ location_table: Dict[int, LocationDict] = { 'map': 10, 'index': 40, 'doom_type': 2006, - 'region': "Icon of Sin (MAP30) Main"}, + 'region': "Icon of Sin (MAP30) Start"}, 361442: {'name': 'Icon of Sin (MAP30) - Chaingun', 'episode': 3, 'map': 10, 'index': 41, 'doom_type': 2002, - 'region': "Icon of Sin (MAP30) Main"}, + 'region': "Icon of Sin (MAP30) Start"}, 361443: {'name': 'Icon of Sin (MAP30) - Chainsaw', 'episode': 3, 'map': 10, 'index': 42, 'doom_type': 2005, - 'region': "Icon of Sin (MAP30) Main"}, + 'region': "Icon of Sin (MAP30) Start"}, 361444: {'name': 'Icon of Sin (MAP30) - Plasma gun', 'episode': 3, 'map': 10, 'index': 43, 'doom_type': 2004, - 'region': "Icon of Sin (MAP30) Main"}, + 'region': "Icon of Sin (MAP30) Start"}, 361445: {'name': 'Icon of Sin (MAP30) - Rocket launcher', 'episode': 3, 'map': 10, 'index': 44, 'doom_type': 2003, - 'region': "Icon of Sin (MAP30) Main"}, + 'region': "Icon of Sin (MAP30) Start"}, 361446: {'name': 'Icon of Sin (MAP30) - Shotgun', 'episode': 3, 'map': 10, 'index': 45, 'doom_type': 2001, - 'region': "Icon of Sin (MAP30) Main"}, + 'region': "Icon of Sin (MAP30) Start"}, 361447: {'name': 'Icon of Sin (MAP30) - Super Shotgun', 'episode': 3, 'map': 10, 'index': 46, 'doom_type': 82, - 'region': "Icon of Sin (MAP30) Main"}, + 'region': "Icon of Sin (MAP30) Start"}, 361448: {'name': 'Icon of Sin (MAP30) - Backpack', 'episode': 3, 'map': 10, 'index': 47, 'doom_type': 8, - 'region': "Icon of Sin (MAP30) Main"}, + 'region': "Icon of Sin (MAP30) Start"}, 361449: {'name': 'Icon of Sin (MAP30) - Megasphere', 'episode': 3, 'map': 10, 'index': 64, 'doom_type': 83, - 'region': "Icon of Sin (MAP30) Main"}, + 'region': "Icon of Sin (MAP30) Start"}, 361450: {'name': 'Icon of Sin (MAP30) - Megasphere 2', 'episode': 3, 'map': 10, @@ -2731,179 +2731,179 @@ location_table: Dict[int, LocationDict] = { 'index': -1, 'doom_type': -1, 'region': "Icon of Sin (MAP30) Main"}, - 361453: {'name': 'Wolfenstein2 (MAP31) - Rocket launcher', + 361453: {'name': 'Wolfenstein (MAP31) - Rocket launcher', 'episode': 4, 'map': 1, 'index': 110, 'doom_type': 2003, - 'region': "Wolfenstein2 (MAP31) Main"}, - 361454: {'name': 'Wolfenstein2 (MAP31) - Shotgun', + 'region': "Wolfenstein (MAP31) Main"}, + 361454: {'name': 'Wolfenstein (MAP31) - Shotgun', 'episode': 4, 'map': 1, 'index': 139, 'doom_type': 2001, - 'region': "Wolfenstein2 (MAP31) Main"}, - 361455: {'name': 'Wolfenstein2 (MAP31) - Berserk', + 'region': "Wolfenstein (MAP31) Main"}, + 361455: {'name': 'Wolfenstein (MAP31) - Berserk', 'episode': 4, 'map': 1, 'index': 263, 'doom_type': 2023, - 'region': "Wolfenstein2 (MAP31) Main"}, - 361456: {'name': 'Wolfenstein2 (MAP31) - Supercharge', + 'region': "Wolfenstein (MAP31) Main"}, + 361456: {'name': 'Wolfenstein (MAP31) - Supercharge', 'episode': 4, 'map': 1, 'index': 278, 'doom_type': 2013, - 'region': "Wolfenstein2 (MAP31) Main"}, - 361457: {'name': 'Wolfenstein2 (MAP31) - Chaingun', + 'region': "Wolfenstein (MAP31) Main"}, + 361457: {'name': 'Wolfenstein (MAP31) - Chaingun', 'episode': 4, 'map': 1, 'index': 305, 'doom_type': 2002, - 'region': "Wolfenstein2 (MAP31) Main"}, - 361458: {'name': 'Wolfenstein2 (MAP31) - Super Shotgun', + 'region': "Wolfenstein (MAP31) Main"}, + 361458: {'name': 'Wolfenstein (MAP31) - Super Shotgun', 'episode': 4, 'map': 1, 'index': 308, 'doom_type': 82, - 'region': "Wolfenstein2 (MAP31) Main"}, - 361459: {'name': 'Wolfenstein2 (MAP31) - Partial invisibility', + 'region': "Wolfenstein (MAP31) Main"}, + 361459: {'name': 'Wolfenstein (MAP31) - Partial invisibility', 'episode': 4, 'map': 1, 'index': 309, 'doom_type': 2024, - 'region': "Wolfenstein2 (MAP31) Main"}, - 361460: {'name': 'Wolfenstein2 (MAP31) - Megasphere', + 'region': "Wolfenstein (MAP31) Main"}, + 361460: {'name': 'Wolfenstein (MAP31) - Megasphere', 'episode': 4, 'map': 1, 'index': 310, 'doom_type': 83, - 'region': "Wolfenstein2 (MAP31) Main"}, - 361461: {'name': 'Wolfenstein2 (MAP31) - Backpack', + 'region': "Wolfenstein (MAP31) Main"}, + 361461: {'name': 'Wolfenstein (MAP31) - Backpack', 'episode': 4, 'map': 1, 'index': 311, 'doom_type': 8, - 'region': "Wolfenstein2 (MAP31) Main"}, - 361462: {'name': 'Wolfenstein2 (MAP31) - Backpack 2', + 'region': "Wolfenstein (MAP31) Main"}, + 361462: {'name': 'Wolfenstein (MAP31) - Backpack 2', 'episode': 4, 'map': 1, 'index': 312, 'doom_type': 8, - 'region': "Wolfenstein2 (MAP31) Main"}, - 361463: {'name': 'Wolfenstein2 (MAP31) - Backpack 3', + 'region': "Wolfenstein (MAP31) Main"}, + 361463: {'name': 'Wolfenstein (MAP31) - Backpack 3', 'episode': 4, 'map': 1, 'index': 313, 'doom_type': 8, - 'region': "Wolfenstein2 (MAP31) Main"}, - 361464: {'name': 'Wolfenstein2 (MAP31) - Backpack 4', + 'region': "Wolfenstein (MAP31) Main"}, + 361464: {'name': 'Wolfenstein (MAP31) - Backpack 4', 'episode': 4, 'map': 1, 'index': 314, 'doom_type': 8, - 'region': "Wolfenstein2 (MAP31) Main"}, - 361465: {'name': 'Wolfenstein2 (MAP31) - BFG9000', + 'region': "Wolfenstein (MAP31) Main"}, + 361465: {'name': 'Wolfenstein (MAP31) - BFG9000', 'episode': 4, 'map': 1, 'index': 315, 'doom_type': 2006, - 'region': "Wolfenstein2 (MAP31) Main"}, - 361466: {'name': 'Wolfenstein2 (MAP31) - Plasma gun', + 'region': "Wolfenstein (MAP31) Main"}, + 361466: {'name': 'Wolfenstein (MAP31) - Plasma gun', 'episode': 4, 'map': 1, 'index': 316, 'doom_type': 2004, - 'region': "Wolfenstein2 (MAP31) Main"}, - 361467: {'name': 'Wolfenstein2 (MAP31) - Exit', + 'region': "Wolfenstein (MAP31) Main"}, + 361467: {'name': 'Wolfenstein (MAP31) - Exit', 'episode': 4, 'map': 1, 'index': -1, 'doom_type': -1, - 'region': "Wolfenstein2 (MAP31) Main"}, - 361468: {'name': 'Grosse2 (MAP32) - Plasma gun', + 'region': "Wolfenstein (MAP31) Main"}, + 361468: {'name': 'Grosse (MAP32) - Plasma gun', 'episode': 4, 'map': 2, 'index': 33, 'doom_type': 2004, - 'region': "Grosse2 (MAP32) Main"}, - 361469: {'name': 'Grosse2 (MAP32) - Rocket launcher', + 'region': "Grosse (MAP32) Main"}, + 361469: {'name': 'Grosse (MAP32) - Rocket launcher', 'episode': 4, 'map': 2, 'index': 57, 'doom_type': 2003, - 'region': "Grosse2 (MAP32) Main"}, - 361470: {'name': 'Grosse2 (MAP32) - Invulnerability', + 'region': "Grosse (MAP32) Start"}, + 361470: {'name': 'Grosse (MAP32) - Invulnerability', 'episode': 4, 'map': 2, 'index': 70, 'doom_type': 2022, - 'region': "Grosse2 (MAP32) Main"}, - 361471: {'name': 'Grosse2 (MAP32) - Super Shotgun', + 'region': "Grosse (MAP32) Main"}, + 361471: {'name': 'Grosse (MAP32) - Super Shotgun', 'episode': 4, 'map': 2, 'index': 74, 'doom_type': 82, - 'region': "Grosse2 (MAP32) Main"}, - 361472: {'name': 'Grosse2 (MAP32) - BFG9000', + 'region': "Grosse (MAP32) Main"}, + 361472: {'name': 'Grosse (MAP32) - BFG9000', 'episode': 4, 'map': 2, 'index': 75, 'doom_type': 2006, - 'region': "Grosse2 (MAP32) Main"}, - 361473: {'name': 'Grosse2 (MAP32) - Megasphere', + 'region': "Grosse (MAP32) Main"}, + 361473: {'name': 'Grosse (MAP32) - Megasphere', 'episode': 4, 'map': 2, 'index': 78, 'doom_type': 83, - 'region': "Grosse2 (MAP32) Main"}, - 361474: {'name': 'Grosse2 (MAP32) - Chaingun', + 'region': "Grosse (MAP32) Main"}, + 361474: {'name': 'Grosse (MAP32) - Chaingun', 'episode': 4, 'map': 2, 'index': 79, 'doom_type': 2002, - 'region': "Grosse2 (MAP32) Main"}, - 361475: {'name': 'Grosse2 (MAP32) - Chaingun 2', + 'region': "Grosse (MAP32) Main"}, + 361475: {'name': 'Grosse (MAP32) - Chaingun 2', 'episode': 4, 'map': 2, 'index': 80, 'doom_type': 2002, - 'region': "Grosse2 (MAP32) Main"}, - 361476: {'name': 'Grosse2 (MAP32) - Chaingun 3', + 'region': "Grosse (MAP32) Main"}, + 361476: {'name': 'Grosse (MAP32) - Chaingun 3', 'episode': 4, 'map': 2, 'index': 81, 'doom_type': 2002, - 'region': "Grosse2 (MAP32) Main"}, - 361477: {'name': 'Grosse2 (MAP32) - Berserk', + 'region': "Grosse (MAP32) Main"}, + 361477: {'name': 'Grosse (MAP32) - Berserk', 'episode': 4, 'map': 2, 'index': 82, 'doom_type': 2023, - 'region': "Grosse2 (MAP32) Main"}, - 361478: {'name': 'Grosse2 (MAP32) - Exit', + 'region': "Grosse (MAP32) Start"}, + 361478: {'name': 'Grosse (MAP32) - Exit', 'episode': 4, 'map': 2, 'index': -1, 'doom_type': -1, - 'region': "Grosse2 (MAP32) Main"}, + 'region': "Grosse (MAP32) Main"}, } location_name_groups: Dict[str, Set[str]] = { - 'Barrels o Fun (MAP23)': { - 'Barrels o Fun (MAP23) - Armor', - 'Barrels o Fun (MAP23) - BFG9000', - 'Barrels o Fun (MAP23) - Backpack', - 'Barrels o Fun (MAP23) - Backpack 2', - 'Barrels o Fun (MAP23) - Berserk', - 'Barrels o Fun (MAP23) - Computer area map', - 'Barrels o Fun (MAP23) - Exit', - 'Barrels o Fun (MAP23) - Megasphere', - 'Barrels o Fun (MAP23) - Rocket launcher', - 'Barrels o Fun (MAP23) - Shotgun', - 'Barrels o Fun (MAP23) - Supercharge', - 'Barrels o Fun (MAP23) - Yellow skull key', + "Barrels o' Fun (MAP23)": { + "Barrels o' Fun (MAP23) - Armor", + "Barrels o' Fun (MAP23) - BFG9000", + "Barrels o' Fun (MAP23) - Backpack", + "Barrels o' Fun (MAP23) - Backpack 2", + "Barrels o' Fun (MAP23) - Berserk", + "Barrels o' Fun (MAP23) - Computer area map", + "Barrels o' Fun (MAP23) - Exit", + "Barrels o' Fun (MAP23) - Megasphere", + "Barrels o' Fun (MAP23) - Rocket launcher", + "Barrels o' Fun (MAP23) - Shotgun", + "Barrels o' Fun (MAP23) - Supercharge", + "Barrels o' Fun (MAP23) - Yellow skull key", }, 'Bloodfalls (MAP25)': { 'Bloodfalls (MAP25) - Armor', @@ -2998,18 +2998,18 @@ location_name_groups: Dict[str, Set[str]] = { 'Gotcha! (MAP20) - Supercharge 3', 'Gotcha! (MAP20) - Supercharge 4', }, - 'Grosse2 (MAP32)': { - 'Grosse2 (MAP32) - BFG9000', - 'Grosse2 (MAP32) - Berserk', - 'Grosse2 (MAP32) - Chaingun', - 'Grosse2 (MAP32) - Chaingun 2', - 'Grosse2 (MAP32) - Chaingun 3', - 'Grosse2 (MAP32) - Exit', - 'Grosse2 (MAP32) - Invulnerability', - 'Grosse2 (MAP32) - Megasphere', - 'Grosse2 (MAP32) - Plasma gun', - 'Grosse2 (MAP32) - Rocket launcher', - 'Grosse2 (MAP32) - Super Shotgun', + 'Grosse (MAP32)': { + 'Grosse (MAP32) - BFG9000', + 'Grosse (MAP32) - Berserk', + 'Grosse (MAP32) - Chaingun', + 'Grosse (MAP32) - Chaingun 2', + 'Grosse (MAP32) - Chaingun 3', + 'Grosse (MAP32) - Exit', + 'Grosse (MAP32) - Invulnerability', + 'Grosse (MAP32) - Megasphere', + 'Grosse (MAP32) - Plasma gun', + 'Grosse (MAP32) - Rocket launcher', + 'Grosse (MAP32) - Super Shotgun', }, 'Icon of Sin (MAP30)': { 'Icon of Sin (MAP30) - BFG9000', @@ -3417,22 +3417,22 @@ location_name_groups: Dict[str, Set[str]] = { 'Underhalls (MAP02) - Red keycard', 'Underhalls (MAP02) - Super Shotgun', }, - 'Wolfenstein2 (MAP31)': { - 'Wolfenstein2 (MAP31) - BFG9000', - 'Wolfenstein2 (MAP31) - Backpack', - 'Wolfenstein2 (MAP31) - Backpack 2', - 'Wolfenstein2 (MAP31) - Backpack 3', - 'Wolfenstein2 (MAP31) - Backpack 4', - 'Wolfenstein2 (MAP31) - Berserk', - 'Wolfenstein2 (MAP31) - Chaingun', - 'Wolfenstein2 (MAP31) - Exit', - 'Wolfenstein2 (MAP31) - Megasphere', - 'Wolfenstein2 (MAP31) - Partial invisibility', - 'Wolfenstein2 (MAP31) - Plasma gun', - 'Wolfenstein2 (MAP31) - Rocket launcher', - 'Wolfenstein2 (MAP31) - Shotgun', - 'Wolfenstein2 (MAP31) - Super Shotgun', - 'Wolfenstein2 (MAP31) - Supercharge', + 'Wolfenstein (MAP31)': { + 'Wolfenstein (MAP31) - BFG9000', + 'Wolfenstein (MAP31) - Backpack', + 'Wolfenstein (MAP31) - Backpack 2', + 'Wolfenstein (MAP31) - Backpack 3', + 'Wolfenstein (MAP31) - Backpack 4', + 'Wolfenstein (MAP31) - Berserk', + 'Wolfenstein (MAP31) - Chaingun', + 'Wolfenstein (MAP31) - Exit', + 'Wolfenstein (MAP31) - Megasphere', + 'Wolfenstein (MAP31) - Partial invisibility', + 'Wolfenstein (MAP31) - Plasma gun', + 'Wolfenstein (MAP31) - Rocket launcher', + 'Wolfenstein (MAP31) - Shotgun', + 'Wolfenstein (MAP31) - Super Shotgun', + 'Wolfenstein (MAP31) - Supercharge', }, } diff --git a/worlds/doom_ii/Maps.py b/worlds/doom_ii/Maps.py index cf41939fa5..d1a42917da 100644 --- a/worlds/doom_ii/Maps.py +++ b/worlds/doom_ii/Maps.py @@ -26,7 +26,7 @@ map_names: List[str] = [ 'Gotcha! (MAP20)', 'Nirvana (MAP21)', 'The Catacombs (MAP22)', - 'Barrels o Fun (MAP23)', + "Barrels o' Fun (MAP23)", 'The Chasm (MAP24)', 'Bloodfalls (MAP25)', 'The Abandoned Mines (MAP26)', @@ -34,6 +34,6 @@ map_names: List[str] = [ 'The Spirit World (MAP28)', 'The Living End (MAP29)', 'Icon of Sin (MAP30)', - 'Wolfenstein2 (MAP31)', - 'Grosse2 (MAP32)', + 'Wolfenstein (MAP31)', + 'Grosse (MAP32)', ] diff --git a/worlds/doom_ii/Regions.py b/worlds/doom_ii/Regions.py index 3d81d7abb8..d953da01cb 100644 --- a/worlds/doom_ii/Regions.py +++ b/worlds/doom_ii/Regions.py @@ -84,11 +84,12 @@ regions:List[RegionDict] = [ # The Waste Tunnels (MAP05) {"name":"The Waste Tunnels (MAP05) Main", - "connects_to_hub":True, + "connects_to_hub":False, "episode":1, "connections":[ {"target":"The Waste Tunnels (MAP05) Red","pro":False}, - {"target":"The Waste Tunnels (MAP05) Blue","pro":False}]}, + {"target":"The Waste Tunnels (MAP05) Blue","pro":False}, + {"target":"The Waste Tunnels (MAP05) Start","pro":False}]}, {"name":"The Waste Tunnels (MAP05) Blue", "connects_to_hub":False, "episode":1, @@ -103,6 +104,10 @@ regions:List[RegionDict] = [ "connects_to_hub":False, "episode":1, "connections":[{"target":"The Waste Tunnels (MAP05) Main","pro":False}]}, + {"name":"The Waste Tunnels (MAP05) Start", + "connects_to_hub":True, + "episode":1, + "connections":[{"target":"The Waste Tunnels (MAP05) Main","pro":False}]}, # The Crusher (MAP06) {"name":"The Crusher (MAP06) Main", @@ -129,9 +134,13 @@ regions:List[RegionDict] = [ # Dead Simple (MAP07) {"name":"Dead Simple (MAP07) Main", + "connects_to_hub":False, + "episode":1, + "connections":[{"target":"Dead Simple (MAP07) Start","pro":False}]}, + {"name":"Dead Simple (MAP07) Start", "connects_to_hub":True, "episode":1, - "connections":[]}, + "connections":[{"target":"Dead Simple (MAP07) Main","pro":False}]}, # Tricks and Traps (MAP08) {"name":"Tricks and Traps (MAP08) Main", @@ -151,11 +160,12 @@ regions:List[RegionDict] = [ # The Pit (MAP09) {"name":"The Pit (MAP09) Main", - "connects_to_hub":True, + "connects_to_hub":False, "episode":1, "connections":[ {"target":"The Pit (MAP09) Yellow","pro":False}, - {"target":"The Pit (MAP09) Blue","pro":False}]}, + {"target":"The Pit (MAP09) Blue","pro":False}, + {"target":"The Pit (MAP09) Start","pro":False}]}, {"name":"The Pit (MAP09) Blue", "connects_to_hub":False, "episode":1, @@ -164,12 +174,18 @@ regions:List[RegionDict] = [ "connects_to_hub":False, "episode":1, "connections":[{"target":"The Pit (MAP09) Main","pro":False}]}, + {"name":"The Pit (MAP09) Start", + "connects_to_hub":True, + "episode":1, + "connections":[{"target":"The Pit (MAP09) Main","pro":False}]}, # Refueling Base (MAP10) {"name":"Refueling Base (MAP10) Main", - "connects_to_hub":True, + "connects_to_hub":False, "episode":1, - "connections":[{"target":"Refueling Base (MAP10) Yellow","pro":False}]}, + "connections":[ + {"target":"Refueling Base (MAP10) Yellow","pro":False}, + {"target":"Refueling Base (MAP10) Start","pro":False}]}, {"name":"Refueling Base (MAP10) Yellow", "connects_to_hub":False, "episode":1, @@ -180,6 +196,10 @@ regions:List[RegionDict] = [ "connects_to_hub":False, "episode":1, "connections":[{"target":"Refueling Base (MAP10) Yellow","pro":False}]}, + {"name":"Refueling Base (MAP10) Start", + "connects_to_hub":True, + "episode":1, + "connections":[{"target":"Refueling Base (MAP10) Main","pro":False}]}, # Circle of Death (MAP11) {"name":"Circle of Death (MAP11) Main", @@ -187,31 +207,49 @@ regions:List[RegionDict] = [ "episode":1, "connections":[ {"target":"Circle of Death (MAP11) Blue","pro":False}, - {"target":"Circle of Death (MAP11) Red","pro":False}]}, + {"target":"Circle of Death (MAP11) Red","pro":False}, + {"target":"Circle of Death (MAP11) Ending","pro":True}]}, {"name":"Circle of Death (MAP11) Blue", "connects_to_hub":False, "episode":1, "connections":[{"target":"Circle of Death (MAP11) Main","pro":False}]}, {"name":"Circle of Death (MAP11) Red", + "connects_to_hub":False, + "episode":1, + "connections":[ + {"target":"Circle of Death (MAP11) Main","pro":False}, + {"target":"Circle of Death (MAP11) Ending","pro":False}]}, + {"name":"Circle of Death (MAP11) Ending", "connects_to_hub":False, "episode":1, "connections":[{"target":"Circle of Death (MAP11) Main","pro":False}]}, # The Factory (MAP12) - {"name":"The Factory (MAP12) Main", - "connects_to_hub":True, + {"name":"The Factory (MAP12) Indoors", + "connects_to_hub":False, "episode":2, "connections":[ {"target":"The Factory (MAP12) Yellow","pro":False}, - {"target":"The Factory (MAP12) Blue","pro":False}]}, + {"target":"The Factory (MAP12) Blue","pro":False}, + {"target":"The Factory (MAP12) Main","pro":False}]}, {"name":"The Factory (MAP12) Blue", "connects_to_hub":False, "episode":2, - "connections":[{"target":"The Factory (MAP12) Main","pro":False}]}, + "connections":[{"target":"The Factory (MAP12) Indoors","pro":False}]}, {"name":"The Factory (MAP12) Yellow", "connects_to_hub":False, "episode":2, "connections":[]}, + {"name":"The Factory (MAP12) Outdoors", + "connects_to_hub":True, + "episode":2, + "connections":[{"target":"The Factory (MAP12) Main","pro":False}]}, + {"name":"The Factory (MAP12) Main", + "connects_to_hub":False, + "episode":2, + "connections":[ + {"target":"The Factory (MAP12) Indoors","pro":False}, + {"target":"The Factory (MAP12) Outdoors","pro":False}]}, # Downtown (MAP13) {"name":"Downtown (MAP13) Main", @@ -291,7 +329,8 @@ regions:List[RegionDict] = [ "episode":2, "connections":[ {"target":"Suburbs (MAP16) Red","pro":False}, - {"target":"Suburbs (MAP16) Blue","pro":False}]}, + {"target":"Suburbs (MAP16) Blue","pro":False}, + {"target":"Suburbs (MAP16) Pro Exit","pro":True}]}, {"name":"Suburbs (MAP16) Blue", "connects_to_hub":False, "episode":2, @@ -299,7 +338,13 @@ regions:List[RegionDict] = [ {"name":"Suburbs (MAP16) Red", "connects_to_hub":False, "episode":2, - "connections":[{"target":"Suburbs (MAP16) Main","pro":False}]}, + "connections":[ + {"target":"Suburbs (MAP16) Main","pro":False}, + {"target":"Suburbs (MAP16) Pro Exit","pro":False}]}, + {"name":"Suburbs (MAP16) Pro Exit", + "connects_to_hub":False, + "episode":2, + "connections":[{"target":"Suburbs (MAP16) Red","pro":False}]}, # Tenements (MAP17) {"name":"Tenements (MAP17) Main", @@ -358,7 +403,7 @@ regions:List[RegionDict] = [ # Nirvana (MAP21) {"name":"Nirvana (MAP21) Main", - "connects_to_hub":True, + "connects_to_hub":False, "episode":3, "connections":[{"target":"Nirvana (MAP21) Yellow","pro":False}]}, {"name":"Nirvana (MAP21) Yellow", @@ -366,19 +411,31 @@ regions:List[RegionDict] = [ "episode":3, "connections":[ {"target":"Nirvana (MAP21) Main","pro":False}, - {"target":"Nirvana (MAP21) Magenta","pro":False}]}, + {"target":"Nirvana (MAP21) Magenta","pro":False}, + {"target":"Nirvana (MAP21) Pro Magenta","pro":True}]}, {"name":"Nirvana (MAP21) Magenta", "connects_to_hub":False, "episode":3, - "connections":[{"target":"Nirvana (MAP21) Yellow","pro":False}]}, + "connections":[ + {"target":"Nirvana (MAP21) Yellow","pro":False}, + {"target":"Nirvana (MAP21) Pro Magenta","pro":False}]}, + {"name":"Nirvana (MAP21) Start", + "connects_to_hub":True, + "episode":3, + "connections":[{"target":"Nirvana (MAP21) Main","pro":False}]}, + {"name":"Nirvana (MAP21) Pro Magenta", + "connects_to_hub":False, + "episode":3, + "connections":[{"target":"Nirvana (MAP21) Magenta","pro":False}]}, # The Catacombs (MAP22) {"name":"The Catacombs (MAP22) Main", - "connects_to_hub":True, + "connects_to_hub":False, "episode":3, "connections":[ {"target":"The Catacombs (MAP22) Blue","pro":False}, - {"target":"The Catacombs (MAP22) Red","pro":False}]}, + {"target":"The Catacombs (MAP22) Red","pro":False}, + {"target":"The Catacombs (MAP22) Early","pro":False}]}, {"name":"The Catacombs (MAP22) Blue", "connects_to_hub":False, "episode":3, @@ -387,36 +444,59 @@ regions:List[RegionDict] = [ "connects_to_hub":False, "episode":3, "connections":[{"target":"The Catacombs (MAP22) Main","pro":False}]}, - - # Barrels o Fun (MAP23) - {"name":"Barrels o Fun (MAP23) Main", + {"name":"The Catacombs (MAP22) Early", "connects_to_hub":True, "episode":3, - "connections":[{"target":"Barrels o Fun (MAP23) Yellow","pro":False}]}, - {"name":"Barrels o Fun (MAP23) Yellow", + "connections":[{"target":"The Catacombs (MAP22) Main","pro":False}]}, + + # Barrels o' Fun (MAP23) + {"name":"Barrels o' Fun (MAP23) Main", + "connects_to_hub":True, + "episode":3, + "connections":[{"target":"Barrels o' Fun (MAP23) Yellow","pro":False}]}, + {"name":"Barrels o' Fun (MAP23) Yellow", "connects_to_hub":False, "episode":3, - "connections":[{"target":"Barrels o Fun (MAP23) Main","pro":False}]}, + "connections":[{"target":"Barrels o' Fun (MAP23) Main","pro":False}]}, # The Chasm (MAP24) {"name":"The Chasm (MAP24) Main", "connects_to_hub":True, "episode":3, - "connections":[{"target":"The Chasm (MAP24) Red","pro":False}]}, + "connections":[ + {"target":"The Chasm (MAP24) Blue","pro":False}, + {"target":"The Chasm (MAP24) Blue Pro","pro":True}]}, {"name":"The Chasm (MAP24) Red", "connects_to_hub":False, "episode":3, - "connections":[{"target":"The Chasm (MAP24) Main","pro":False}]}, + "connections":[{"target":"The Chasm (MAP24) Blue","pro":False}]}, + {"name":"The Chasm (MAP24) Blue", + "connects_to_hub":False, + "episode":3, + "connections":[ + {"target":"The Chasm (MAP24) Red","pro":False}, + {"target":"The Chasm (MAP24) Main","pro":False}, + {"target":"The Chasm (MAP24) Blue Pro","pro":False}]}, + {"name":"The Chasm (MAP24) Blue Pro", + "connects_to_hub":False, + "episode":3, + "connections":[{"target":"The Chasm (MAP24) Blue","pro":False}]}, # Bloodfalls (MAP25) {"name":"Bloodfalls (MAP25) Main", - "connects_to_hub":True, + "connects_to_hub":False, "episode":3, - "connections":[{"target":"Bloodfalls (MAP25) Blue","pro":False}]}, + "connections":[ + {"target":"Bloodfalls (MAP25) Blue","pro":False}, + {"target":"Bloodfalls (MAP25) Start","pro":False}]}, {"name":"Bloodfalls (MAP25) Blue", "connects_to_hub":False, "episode":3, "connections":[{"target":"Bloodfalls (MAP25) Main","pro":False}]}, + {"name":"Bloodfalls (MAP25) Start", + "connects_to_hub":True, + "episode":3, + "connections":[{"target":"Bloodfalls (MAP25) Main","pro":False}]}, # The Abandoned Mines (MAP26) {"name":"The Abandoned Mines (MAP26) Main", @@ -484,19 +564,27 @@ regions:List[RegionDict] = [ # Icon of Sin (MAP30) {"name":"Icon of Sin (MAP30) Main", + "connects_to_hub":False, + "episode":3, + "connections":[{"target":"Icon of Sin (MAP30) Start","pro":False}]}, + {"name":"Icon of Sin (MAP30) Start", "connects_to_hub":True, "episode":3, - "connections":[]}, + "connections":[{"target":"Icon of Sin (MAP30) Main","pro":False}]}, - # Wolfenstein2 (MAP31) - {"name":"Wolfenstein2 (MAP31) Main", + # Wolfenstein (MAP31) + {"name":"Wolfenstein (MAP31) Main", "connects_to_hub":True, "episode":4, "connections":[]}, - # Grosse2 (MAP32) - {"name":"Grosse2 (MAP32) Main", + # Grosse (MAP32) + {"name":"Grosse (MAP32) Main", + "connects_to_hub":False, + "episode":4, + "connections":[{"target":"Grosse (MAP32) Start","pro":False}]}, + {"name":"Grosse (MAP32) Start", "connects_to_hub":True, "episode":4, - "connections":[]}, + "connections":[{"target":"Grosse (MAP32) Main","pro":False}]}, ] diff --git a/worlds/doom_ii/Rules.py b/worlds/doom_ii/Rules.py index 139733c0ea..c6913991aa 100644 --- a/worlds/doom_ii/Rules.py +++ b/worlds/doom_ii/Rules.py @@ -53,14 +53,6 @@ def set_episode1_rules(player, multiworld, pro): state.has("The Focus (MAP04) - Red keycard", player, 1)) # The Waste Tunnels (MAP05) - set_rule(multiworld.get_entrance("Hub -> The Waste Tunnels (MAP05) Main", player), lambda state: - (state.has("The Waste Tunnels (MAP05)", player, 1) and - state.has("Shotgun", player, 1) and - state.has("Chaingun", player, 1) and - state.has("Super Shotgun", player, 1)) and - (state.has("Rocket launcher", player, 1) or - state.has("Plasma gun", player, 1) or - state.has("BFG9000", player, 1))) set_rule(multiworld.get_entrance("The Waste Tunnels (MAP05) Main -> The Waste Tunnels (MAP05) Red", player), lambda state: state.has("The Waste Tunnels (MAP05) - Red keycard", player, 1)) set_rule(multiworld.get_entrance("The Waste Tunnels (MAP05) Main -> The Waste Tunnels (MAP05) Blue", player), lambda state: @@ -71,18 +63,22 @@ def set_episode1_rules(player, multiworld, pro): state.has("The Waste Tunnels (MAP05) - Blue keycard", player, 1)) set_rule(multiworld.get_entrance("The Waste Tunnels (MAP05) Yellow -> The Waste Tunnels (MAP05) Blue", player), lambda state: state.has("The Waste Tunnels (MAP05) - Yellow keycard", player, 1)) + set_rule(multiworld.get_entrance("Hub -> The Waste Tunnels (MAP05) Start", player), lambda state: + state.has("The Waste Tunnels (MAP05)", player, 1)) + set_rule(multiworld.get_entrance("The Waste Tunnels (MAP05) Start -> The Waste Tunnels (MAP05) Main", player), lambda state: + (state.has("Shotgun", player, 1) and + state.has("Super Shotgun", player, 1)) and (state.has("Chaingun", player, 1) or + state.has("Plasma gun", player, 1))) # The Crusher (MAP06) set_rule(multiworld.get_entrance("Hub -> The Crusher (MAP06) Main", player), lambda state: (state.has("The Crusher (MAP06)", player, 1) and - state.has("Shotgun", player, 1) and - state.has("Chaingun", player, 1) and - state.has("Super Shotgun", player, 1)) and - (state.has("Rocket launcher", player, 1) or - state.has("Plasma gun", player, 1) or - state.has("BFG9000", player, 1))) + state.has("Shotgun", player, 1)) and + (state.has("Plasma gun", player, 1) or + state.has("Chaingun", player, 1))) set_rule(multiworld.get_entrance("The Crusher (MAP06) Main -> The Crusher (MAP06) Blue", player), lambda state: - state.has("The Crusher (MAP06) - Blue keycard", player, 1)) + state.has("The Crusher (MAP06) - Blue keycard", player, 1) and + state.has("Super Shotgun", player, 1)) set_rule(multiworld.get_entrance("The Crusher (MAP06) Blue -> The Crusher (MAP06) Red", player), lambda state: state.has("The Crusher (MAP06) - Red keycard", player, 1)) set_rule(multiworld.get_entrance("The Crusher (MAP06) Blue -> The Crusher (MAP06) Main", player), lambda state: @@ -95,14 +91,14 @@ def set_episode1_rules(player, multiworld, pro): state.has("The Crusher (MAP06) - Red keycard", player, 1)) # Dead Simple (MAP07) - set_rule(multiworld.get_entrance("Hub -> Dead Simple (MAP07) Main", player), lambda state: - (state.has("Dead Simple (MAP07)", player, 1) and - state.has("Shotgun", player, 1) and + set_rule(multiworld.get_entrance("Hub -> Dead Simple (MAP07) Start", player), lambda state: + state.has("Dead Simple (MAP07)", player, 1)) + set_rule(multiworld.get_entrance("Dead Simple (MAP07) Start -> Dead Simple (MAP07) Main", player), lambda state: + (state.has("Shotgun", player, 1) and state.has("Chaingun", player, 1) and - state.has("Super Shotgun", player, 1)) and - (state.has("Rocket launcher", player, 1) or + state.has("Super Shotgun", player, 1)) and (state.has("BFG9000", player, 1) or state.has("Plasma gun", player, 1) or - state.has("BFG9000", player, 1))) + state.has("Rocket launcher", player, 1))) # Tricks and Traps (MAP08) set_rule(multiworld.get_entrance("Hub -> Tricks and Traps (MAP08) Main", player), lambda state: @@ -119,34 +115,34 @@ def set_episode1_rules(player, multiworld, pro): state.has("Tricks and Traps (MAP08) - Yellow skull key", player, 1)) # The Pit (MAP09) - set_rule(multiworld.get_entrance("Hub -> The Pit (MAP09) Main", player), lambda state: - (state.has("The Pit (MAP09)", player, 1) and - state.has("Shotgun", player, 1) and - state.has("Chaingun", player, 1) and - state.has("Super Shotgun", player, 1)) and - (state.has("Rocket launcher", player, 1) or - state.has("Plasma gun", player, 1) or - state.has("BFG9000", player, 1))) set_rule(multiworld.get_entrance("The Pit (MAP09) Main -> The Pit (MAP09) Yellow", player), lambda state: state.has("The Pit (MAP09) - Yellow keycard", player, 1)) set_rule(multiworld.get_entrance("The Pit (MAP09) Main -> The Pit (MAP09) Blue", player), lambda state: state.has("The Pit (MAP09) - Blue keycard", player, 1)) set_rule(multiworld.get_entrance("The Pit (MAP09) Yellow -> The Pit (MAP09) Main", player), lambda state: state.has("The Pit (MAP09) - Yellow keycard", player, 1)) + set_rule(multiworld.get_entrance("Hub -> The Pit (MAP09) Start", player), lambda state: + state.has("The Pit (MAP09)", player, 1)) + set_rule(multiworld.get_entrance("The Pit (MAP09) Start -> The Pit (MAP09) Main", player), lambda state: + (state.has("Shotgun", player, 1) and + state.has("Chaingun", player, 1) and + state.has("Super Shotgun", player, 1)) and (state.has("BFG9000", player, 1) or + state.has("Plasma gun", player, 1) or + state.has("Rocket launcher", player, 1))) # Refueling Base (MAP10) - set_rule(multiworld.get_entrance("Hub -> Refueling Base (MAP10) Main", player), lambda state: - (state.has("Refueling Base (MAP10)", player, 1) and - state.has("Shotgun", player, 1) and - state.has("Chaingun", player, 1) and - state.has("Super Shotgun", player, 1)) and - (state.has("Rocket launcher", player, 1) or - state.has("Plasma gun", player, 1) or - state.has("BFG9000", player, 1))) set_rule(multiworld.get_entrance("Refueling Base (MAP10) Main -> Refueling Base (MAP10) Yellow", player), lambda state: state.has("Refueling Base (MAP10) - Yellow keycard", player, 1)) set_rule(multiworld.get_entrance("Refueling Base (MAP10) Yellow -> Refueling Base (MAP10) Yellow Blue", player), lambda state: state.has("Refueling Base (MAP10) - Blue keycard", player, 1)) + set_rule(multiworld.get_entrance("Hub -> Refueling Base (MAP10) Start", player), lambda state: + state.has("Refueling Base (MAP10)", player, 1)) + set_rule(multiworld.get_entrance("Refueling Base (MAP10) Start -> Refueling Base (MAP10) Main", player), lambda state: + (state.has("Shotgun", player, 1) and + state.has("Chaingun", player, 1) and + state.has("Super Shotgun", player, 1)) and (state.has("BFG9000", player, 1) or + state.has("Plasma gun", player, 1) or + state.has("Rocket launcher", player, 1))) # Circle of Death (MAP11) set_rule(multiworld.get_entrance("Hub -> Circle of Death (MAP11) Main", player), lambda state: @@ -165,18 +161,19 @@ def set_episode1_rules(player, multiworld, pro): def set_episode2_rules(player, multiworld, pro): # The Factory (MAP12) - set_rule(multiworld.get_entrance("Hub -> The Factory (MAP12) Main", player), lambda state: - (state.has("The Factory (MAP12)", player, 1) and - state.has("Shotgun", player, 1) and - state.has("Chaingun", player, 1) and - state.has("Super Shotgun", player, 1)) and - (state.has("Rocket launcher", player, 1) or - state.has("Plasma gun", player, 1) or - state.has("BFG9000", player, 1))) - set_rule(multiworld.get_entrance("The Factory (MAP12) Main -> The Factory (MAP12) Yellow", player), lambda state: + set_rule(multiworld.get_entrance("The Factory (MAP12) Indoors -> The Factory (MAP12) Yellow", player), lambda state: state.has("The Factory (MAP12) - Yellow keycard", player, 1)) - set_rule(multiworld.get_entrance("The Factory (MAP12) Main -> The Factory (MAP12) Blue", player), lambda state: + set_rule(multiworld.get_entrance("The Factory (MAP12) Indoors -> The Factory (MAP12) Blue", player), lambda state: state.has("The Factory (MAP12) - Blue keycard", player, 1)) + set_rule(multiworld.get_entrance("Hub -> The Factory (MAP12) Outdoors", player), lambda state: + state.has("The Factory (MAP12)", player, 1)) + set_rule(multiworld.get_entrance("The Factory (MAP12) Outdoors -> The Factory (MAP12) Main", player), lambda state: + state.has("Super Shotgun", player, 1) or + state.has("Plasma gun", player, 1)) + set_rule(multiworld.get_entrance("The Factory (MAP12) Main -> The Factory (MAP12) Indoors", player), lambda state: + (state.has("Super Shotgun", player, 1) and + state.has("Chaingun", player, 1)) and (state.has("BFG9000", player, 1) or + state.has("Plasma gun", player, 1))) # Downtown (MAP13) set_rule(multiworld.get_entrance("Hub -> Downtown (MAP13) Main", player), lambda state: @@ -307,54 +304,56 @@ def set_episode2_rules(player, multiworld, pro): def set_episode3_rules(player, multiworld, pro): # Nirvana (MAP21) - set_rule(multiworld.get_entrance("Hub -> Nirvana (MAP21) Main", player), lambda state: - (state.has("Nirvana (MAP21)", player, 1) and - state.has("Shotgun", player, 1) and - state.has("Chaingun", player, 1) and - state.has("Super Shotgun", player, 1)) and - (state.has("Rocket launcher", player, 1) or - state.has("Plasma gun", player, 1) or - state.has("BFG9000", player, 1))) set_rule(multiworld.get_entrance("Nirvana (MAP21) Main -> Nirvana (MAP21) Yellow", player), lambda state: - state.has("Nirvana (MAP21) - Yellow skull key", player, 1)) + (state.has("Super Shotgun", player, 1) and + state.has("Chaingun", player, 1) and + state.has("Nirvana (MAP21) - Yellow skull key", player, 1)) and (state.has("Plasma gun", player, 1) or + state.has("BFG9000", player, 1))) set_rule(multiworld.get_entrance("Nirvana (MAP21) Yellow -> Nirvana (MAP21) Main", player), lambda state: state.has("Nirvana (MAP21) - Yellow skull key", player, 1)) set_rule(multiworld.get_entrance("Nirvana (MAP21) Yellow -> Nirvana (MAP21) Magenta", player), lambda state: state.has("Nirvana (MAP21) - Red skull key", player, 1) and state.has("Nirvana (MAP21) - Blue skull key", player, 1)) - set_rule(multiworld.get_entrance("Nirvana (MAP21) Magenta -> Nirvana (MAP21) Yellow", player), lambda state: - state.has("Nirvana (MAP21) - Red skull key", player, 1) and - state.has("Nirvana (MAP21) - Blue skull key", player, 1)) + set_rule(multiworld.get_entrance("Hub -> Nirvana (MAP21) Start", player), lambda state: + state.has("Nirvana (MAP21)", player, 1)) + set_rule(multiworld.get_entrance("Nirvana (MAP21) Start -> Nirvana (MAP21) Main", player), lambda state: + state.has("Super Shotgun", player, 1) or + state.has("Plasma gun", player, 1)) + set_rule(multiworld.get_entrance("Nirvana (MAP21) Pro Magenta -> Nirvana (MAP21) Magenta", player), lambda state: + state.has("Nirvana (MAP21) - Red skull key", player, 1)) # The Catacombs (MAP22) - set_rule(multiworld.get_entrance("Hub -> The Catacombs (MAP22) Main", player), lambda state: - (state.has("The Catacombs (MAP22)", player, 1) and - state.has("Shotgun", player, 1) and - state.has("Chaingun", player, 1) and - state.has("Super Shotgun", player, 1)) and - (state.has("BFG9000", player, 1) or - state.has("Rocket launcher", player, 1) or - state.has("Plasma gun", player, 1))) set_rule(multiworld.get_entrance("The Catacombs (MAP22) Main -> The Catacombs (MAP22) Blue", player), lambda state: state.has("The Catacombs (MAP22) - Blue skull key", player, 1)) set_rule(multiworld.get_entrance("The Catacombs (MAP22) Main -> The Catacombs (MAP22) Red", player), lambda state: state.has("The Catacombs (MAP22) - Red skull key", player, 1)) set_rule(multiworld.get_entrance("The Catacombs (MAP22) Red -> The Catacombs (MAP22) Main", player), lambda state: state.has("The Catacombs (MAP22) - Red skull key", player, 1)) + set_rule(multiworld.get_entrance("Hub -> The Catacombs (MAP22) Early", player), lambda state: + (state.has("The Catacombs (MAP22)", player, 1)) and + (state.has("Shotgun", player, 1) or + state.has("Super Shotgun", player, 1) or + state.has("Plasma gun", player, 1))) + set_rule(multiworld.get_entrance("The Catacombs (MAP22) Early -> The Catacombs (MAP22) Main", player), lambda state: + (state.has("Shotgun", player, 1) and + state.has("Chaingun", player, 1) and + state.has("Super Shotgun", player, 1)) and (state.has("BFG9000", player, 1) or + state.has("Plasma gun", player, 1) or + state.has("Rocket launcher", player, 1))) - # Barrels o Fun (MAP23) - set_rule(multiworld.get_entrance("Hub -> Barrels o Fun (MAP23) Main", player), lambda state: - (state.has("Barrels o Fun (MAP23)", player, 1) and + # Barrels o' Fun (MAP23) + set_rule(multiworld.get_entrance("Hub -> Barrels o' Fun (MAP23) Main", player), lambda state: + (state.has("Barrels o' Fun (MAP23)", player, 1) and state.has("Shotgun", player, 1) and state.has("Chaingun", player, 1) and state.has("Super Shotgun", player, 1)) and (state.has("Rocket launcher", player, 1) or state.has("Plasma gun", player, 1) or state.has("BFG9000", player, 1))) - set_rule(multiworld.get_entrance("Barrels o Fun (MAP23) Main -> Barrels o Fun (MAP23) Yellow", player), lambda state: - state.has("Barrels o Fun (MAP23) - Yellow skull key", player, 1)) - set_rule(multiworld.get_entrance("Barrels o Fun (MAP23) Yellow -> Barrels o Fun (MAP23) Main", player), lambda state: - state.has("Barrels o Fun (MAP23) - Yellow skull key", player, 1)) + set_rule(multiworld.get_entrance("Barrels o' Fun (MAP23) Main -> Barrels o' Fun (MAP23) Yellow", player), lambda state: + state.has("Barrels o' Fun (MAP23) - Yellow skull key", player, 1)) + set_rule(multiworld.get_entrance("Barrels o' Fun (MAP23) Yellow -> Barrels o' Fun (MAP23) Main", player), lambda state: + state.has("Barrels o' Fun (MAP23) - Yellow skull key", player, 1)) # The Chasm (MAP24) set_rule(multiworld.get_entrance("Hub -> The Chasm (MAP24) Main", player), lambda state: @@ -365,24 +364,26 @@ def set_episode3_rules(player, multiworld, pro): state.has("Plasma gun", player, 1) and state.has("BFG9000", player, 1) and state.has("Super Shotgun", player, 1)) - set_rule(multiworld.get_entrance("The Chasm (MAP24) Main -> The Chasm (MAP24) Red", player), lambda state: + set_rule(multiworld.get_entrance("The Chasm (MAP24) Main -> The Chasm (MAP24) Blue", player), lambda state: + state.has("The Chasm (MAP24) - Blue keycard", player, 1)) + set_rule(multiworld.get_entrance("The Chasm (MAP24) Red -> The Chasm (MAP24) Blue", player), lambda state: state.has("The Chasm (MAP24) - Red keycard", player, 1)) - set_rule(multiworld.get_entrance("The Chasm (MAP24) Red -> The Chasm (MAP24) Main", player), lambda state: + set_rule(multiworld.get_entrance("The Chasm (MAP24) Blue -> The Chasm (MAP24) Red", player), lambda state: state.has("The Chasm (MAP24) - Red keycard", player, 1)) # Bloodfalls (MAP25) - set_rule(multiworld.get_entrance("Hub -> Bloodfalls (MAP25) Main", player), lambda state: - state.has("Bloodfalls (MAP25)", player, 1) and - state.has("Shotgun", player, 1) and - state.has("Chaingun", player, 1) and - state.has("Rocket launcher", player, 1) and - state.has("Plasma gun", player, 1) and - state.has("BFG9000", player, 1) and - state.has("Super Shotgun", player, 1)) set_rule(multiworld.get_entrance("Bloodfalls (MAP25) Main -> Bloodfalls (MAP25) Blue", player), lambda state: - state.has("Bloodfalls (MAP25) - Blue skull key", player, 1)) + (state.has("Bloodfalls (MAP25) - Blue skull key", player, 1)) and (state.has("Rocket launcher", player, 1) or + state.has("Plasma gun", player, 1) or + state.has("BFG9000", player, 1))) set_rule(multiworld.get_entrance("Bloodfalls (MAP25) Blue -> Bloodfalls (MAP25) Main", player), lambda state: state.has("Bloodfalls (MAP25) - Blue skull key", player, 1)) + set_rule(multiworld.get_entrance("Hub -> Bloodfalls (MAP25) Start", player), lambda state: + state.has("Bloodfalls (MAP25)", player, 1)) + set_rule(multiworld.get_entrance("Bloodfalls (MAP25) Start -> Bloodfalls (MAP25) Main", player), lambda state: + state.has("Super Shotgun", player, 1) and + state.has("Chaingun", player, 1) and + state.has("Shotgun", player, 1)) # The Abandoned Mines (MAP26) set_rule(multiworld.get_entrance("Hub -> The Abandoned Mines (MAP26) Main", player), lambda state: @@ -451,36 +452,34 @@ def set_episode3_rules(player, multiworld, pro): state.has("Super Shotgun", player, 1)) # Icon of Sin (MAP30) - set_rule(multiworld.get_entrance("Hub -> Icon of Sin (MAP30) Main", player), lambda state: - state.has("Icon of Sin (MAP30)", player, 1) and - state.has("Rocket launcher", player, 1) and + set_rule(multiworld.get_entrance("Hub -> Icon of Sin (MAP30) Start", player), lambda state: + state.has("Icon of Sin (MAP30)", player, 1)) + set_rule(multiworld.get_entrance("Icon of Sin (MAP30) Start -> Icon of Sin (MAP30) Main", player), lambda state: state.has("Shotgun", player, 1) and - state.has("Chaingun", player, 1) and + state.has("Rocket launcher", player, 1) and state.has("Plasma gun", player, 1) and + state.has("Chaingun", player, 1) and state.has("BFG9000", player, 1) and state.has("Super Shotgun", player, 1)) def set_episode4_rules(player, multiworld, pro): - # Wolfenstein2 (MAP31) - set_rule(multiworld.get_entrance("Hub -> Wolfenstein2 (MAP31) Main", player), lambda state: - (state.has("Wolfenstein2 (MAP31)", player, 1) and - state.has("Shotgun", player, 1) and - state.has("Chaingun", player, 1) and - state.has("Super Shotgun", player, 1)) and - (state.has("Rocket launcher", player, 1) or - state.has("Plasma gun", player, 1) or - state.has("BFG9000", player, 1))) + # Wolfenstein (MAP31) + set_rule(multiworld.get_entrance("Hub -> Wolfenstein (MAP31) Main", player), lambda state: + (state.has("Wolfenstein (MAP31)", player, 1) and + state.has("Chaingun", player, 1)) and + (state.has("Shotgun", player, 1) or + state.has("Super Shotgun", player, 1))) - # Grosse2 (MAP32) - set_rule(multiworld.get_entrance("Hub -> Grosse2 (MAP32) Main", player), lambda state: - (state.has("Grosse2 (MAP32)", player, 1) and - state.has("Shotgun", player, 1) and + # Grosse (MAP32) + set_rule(multiworld.get_entrance("Hub -> Grosse (MAP32) Start", player), lambda state: + state.has("Grosse (MAP32)", player, 1)) + set_rule(multiworld.get_entrance("Grosse (MAP32) Start -> Grosse (MAP32) Main", player), lambda state: + (state.has("Shotgun", player, 1) and state.has("Chaingun", player, 1) and - state.has("Super Shotgun", player, 1)) and - (state.has("Rocket launcher", player, 1) or + state.has("Super Shotgun", player, 1)) and (state.has("BFG9000", player, 1) or state.has("Plasma gun", player, 1) or - state.has("BFG9000", player, 1))) + state.has("Rocket launcher", player, 1))) def set_rules(doom_ii_world: "DOOM2World", included_episodes, pro): diff --git a/worlds/doom_ii/__init__.py b/worlds/doom_ii/__init__.py index 815e21419a..6416ffea6a 100644 --- a/worlds/doom_ii/__init__.py +++ b/worlds/doom_ii/__init__.py @@ -51,11 +51,11 @@ class DOOM2World(World): location_name_to_id = {data["name"]: loc_id for loc_id, data in Locations.location_table.items()} location_name_groups = Locations.location_name_groups - starting_level_for_episode: List[str] = [ - "Entryway (MAP01)", - "The Factory (MAP12)", - "Nirvana (MAP21)" - ] + starting_level_for_episode: Dict[int, str] = { + 1: "Entryway (MAP01)", + 2: "The Factory (MAP12)", + 3: "Nirvana (MAP21)" + } # Item ratio that scales depending on episode count. These are the ratio for 3 episode. In DOOM1. # The ratio have been tweaked seem, and feel good. @@ -77,6 +77,7 @@ class DOOM2World(World): def __init__(self, multiworld: MultiWorld, player: int): self.included_episodes = [1, 1, 1, 0] self.location_count = 0 + self.starting_levels = [] super().__init__(multiworld, player) @@ -95,6 +96,14 @@ class DOOM2World(World): if self.get_episode_count() == 0: self.included_episodes[0] = 1 + self.starting_levels = [level_name for (episode, level_name) in self.starting_level_for_episode.items() + if self.included_episodes[episode - 1]] + + # If soloing MAP21-MAP30, we need to mark a weapon as early to help generation succeed + if self.get_episode_count() == 1 and self.included_episodes[2]: + early_weapon = self.random.choice(["Super Shotgun", "Plasma gun"]) + self.multiworld.early_items[self.player][early_weapon] = 1 + def create_regions(self): pro = self.options.pro.value @@ -193,7 +202,7 @@ class DOOM2World(World): if item["episode"] != -1 and not self.included_episodes[item["episode"] - 1]: continue - count = item["count"] if item["name"] not in self.starting_level_for_episode else item["count"] - 1 + count = item["count"] if item["name"] not in self.starting_levels else item["count"] - 1 itempool += [self.create_item(item["name"]) for _ in range(count)] # Backpack(s) based on options @@ -224,9 +233,8 @@ class DOOM2World(World): self.location_count -= 1 # Give starting levels right away - for i in range(len(self.starting_level_for_episode)): - if self.included_episodes[i]: - self.multiworld.push_precollected(self.create_item(self.starting_level_for_episode[i])) + for map_name in self.starting_levels: + self.multiworld.push_precollected(self.create_item(map_name)) # Give Computer area maps if option selected if start_with_computer_area_maps: diff --git a/worlds/heretic/Items.py b/worlds/heretic/Items.py index 777bf06cda..b8eb5cd670 100644 --- a/worlds/heretic/Items.py +++ b/worlds/heretic/Items.py @@ -514,19 +514,19 @@ item_table: Dict[int, ItemDict] = { 'map': 7}, 370259: {'classification': ItemClassification.progression, 'count': 1, - 'name': 'The Aquifier (E3M9) - Blue key', + 'name': 'The Aquifer (E3M9) - Blue key', 'doom_type': 79, 'episode': 3, 'map': 9}, 370260: {'classification': ItemClassification.progression, 'count': 1, - 'name': 'The Aquifier (E3M9) - Green key', + 'name': 'The Aquifer (E3M9) - Green key', 'doom_type': 73, 'episode': 3, 'map': 9}, 370261: {'classification': ItemClassification.progression, 'count': 1, - 'name': 'The Aquifier (E3M9) - Yellow key', + 'name': 'The Aquifer (E3M9) - Yellow key', 'doom_type': 80, 'episode': 3, 'map': 9}, @@ -1234,37 +1234,37 @@ item_table: Dict[int, ItemDict] = { 'map': 7}, 370475: {'classification': ItemClassification.progression, 'count': 1, - 'name': "D'Sparil'S Keep (E3M8)", + 'name': "D'Sparil's Keep (E3M8)", 'doom_type': -1, 'episode': 3, 'map': 8}, 370476: {'classification': ItemClassification.progression, 'count': 1, - 'name': "D'Sparil'S Keep (E3M8) - Complete", + 'name': "D'Sparil's Keep (E3M8) - Complete", 'doom_type': -2, 'episode': 3, 'map': 8}, 370477: {'classification': ItemClassification.filler, 'count': 1, - 'name': "D'Sparil'S Keep (E3M8) - Map Scroll", + 'name': "D'Sparil's Keep (E3M8) - Map Scroll", 'doom_type': 35, 'episode': 3, 'map': 8}, 370478: {'classification': ItemClassification.progression, 'count': 1, - 'name': 'The Aquifier (E3M9)', + 'name': 'The Aquifer (E3M9)', 'doom_type': -1, 'episode': 3, 'map': 9}, 370479: {'classification': ItemClassification.progression, 'count': 1, - 'name': 'The Aquifier (E3M9) - Complete', + 'name': 'The Aquifer (E3M9) - Complete', 'doom_type': -2, 'episode': 3, 'map': 9}, 370480: {'classification': ItemClassification.filler, 'count': 1, - 'name': 'The Aquifier (E3M9) - Map Scroll', + 'name': 'The Aquifer (E3M9) - Map Scroll', 'doom_type': 35, 'episode': 3, 'map': 9}, @@ -1635,8 +1635,8 @@ item_name_groups: Dict[str, Set[str]] = { 'Ammos': {'Crystal Geode', 'Energy Orb', 'Greater Runes', 'Inferno Orb', 'Pile of Mace Spheres', 'Quiver of Ethereal Arrows', }, 'Armors': {'Enchanted Shield', 'Silver Shield', }, 'Artifacts': {'Chaos Device', 'Morph Ovum', 'Mystic Urn', 'Quartz Flask', 'Ring of Invincibility', 'Shadowsphere', 'Timebomb of the Ancients', 'Tome of Power', 'Torch', }, - 'Keys': {'Ambulatory (E4M3) - Blue key', 'Ambulatory (E4M3) - Green key', 'Ambulatory (E4M3) - Yellow key', 'Blockhouse (E4M2) - Blue key', 'Blockhouse (E4M2) - Green key', 'Blockhouse (E4M2) - Yellow key', 'Catafalque (E4M1) - Green key', 'Catafalque (E4M1) - Yellow key', 'Colonnade (E5M6) - Blue key', 'Colonnade (E5M6) - Green key', 'Colonnade (E5M6) - Yellow key', 'Courtyard (E5M4) - Blue key', 'Courtyard (E5M4) - Green key', 'Courtyard (E5M4) - Yellow key', 'Foetid Manse (E5M7) - Blue key', 'Foetid Manse (E5M7) - Green key', 'Foetid Manse (E5M7) - Yellow key', 'Great Stair (E4M5) - Blue key', 'Great Stair (E4M5) - Green key', 'Great Stair (E4M5) - Yellow key', 'Halls of the Apostate (E4M6) - Blue key', 'Halls of the Apostate (E4M6) - Green key', 'Halls of the Apostate (E4M6) - Yellow key', 'Hydratyr (E5M5) - Blue key', 'Hydratyr (E5M5) - Green key', 'Hydratyr (E5M5) - Yellow key', 'Mausoleum (E4M9) - Yellow key', 'Ochre Cliffs (E5M1) - Blue key', 'Ochre Cliffs (E5M1) - Green key', 'Ochre Cliffs (E5M1) - Yellow key', 'Quay (E5M3) - Blue key', 'Quay (E5M3) - Green key', 'Quay (E5M3) - Yellow key', 'Ramparts of Perdition (E4M7) - Blue key', 'Ramparts of Perdition (E4M7) - Green key', 'Ramparts of Perdition (E4M7) - Yellow key', 'Rapids (E5M2) - Green key', 'Rapids (E5M2) - Yellow key', 'Shattered Bridge (E4M8) - Yellow key', "Skein of D'Sparil (E5M9) - Blue key", "Skein of D'Sparil (E5M9) - Green key", "Skein of D'Sparil (E5M9) - Yellow key", 'The Aquifier (E3M9) - Blue key', 'The Aquifier (E3M9) - Green key', 'The Aquifier (E3M9) - Yellow key', 'The Azure Fortress (E3M4) - Green key', 'The Azure Fortress (E3M4) - Yellow key', 'The Catacombs (E2M5) - Blue key', 'The Catacombs (E2M5) - Green key', 'The Catacombs (E2M5) - Yellow key', 'The Cathedral (E1M6) - Green key', 'The Cathedral (E1M6) - Yellow key', 'The Cesspool (E3M2) - Blue key', 'The Cesspool (E3M2) - Green key', 'The Cesspool (E3M2) - Yellow key', 'The Chasm (E3M7) - Blue key', 'The Chasm (E3M7) - Green key', 'The Chasm (E3M7) - Yellow key', 'The Citadel (E1M5) - Blue key', 'The Citadel (E1M5) - Green key', 'The Citadel (E1M5) - Yellow key', 'The Confluence (E3M3) - Blue key', 'The Confluence (E3M3) - Green key', 'The Confluence (E3M3) - Yellow key', 'The Crater (E2M1) - Green key', 'The Crater (E2M1) - Yellow key', 'The Crypts (E1M7) - Blue key', 'The Crypts (E1M7) - Green key', 'The Crypts (E1M7) - Yellow key', 'The Docks (E1M1) - Yellow key', 'The Dungeons (E1M2) - Blue key', 'The Dungeons (E1M2) - Green key', 'The Dungeons (E1M2) - Yellow key', 'The Gatehouse (E1M3) - Green key', 'The Gatehouse (E1M3) - Yellow key', 'The Glacier (E2M9) - Blue key', 'The Glacier (E2M9) - Green key', 'The Glacier (E2M9) - Yellow key', 'The Graveyard (E1M9) - Blue key', 'The Graveyard (E1M9) - Green key', 'The Graveyard (E1M9) - Yellow key', 'The Great Hall (E2M7) - Blue key', 'The Great Hall (E2M7) - Green key', 'The Great Hall (E2M7) - Yellow key', 'The Guard Tower (E1M4) - Green key', 'The Guard Tower (E1M4) - Yellow key', 'The Halls of Fear (E3M6) - Blue key', 'The Halls of Fear (E3M6) - Green key', 'The Halls of Fear (E3M6) - Yellow key', 'The Ice Grotto (E2M4) - Blue key', 'The Ice Grotto (E2M4) - Green key', 'The Ice Grotto (E2M4) - Yellow key', 'The Labyrinth (E2M6) - Blue key', 'The Labyrinth (E2M6) - Green key', 'The Labyrinth (E2M6) - Yellow key', 'The Lava Pits (E2M2) - Green key', 'The Lava Pits (E2M2) - Yellow key', 'The Ophidian Lair (E3M5) - Green key', 'The Ophidian Lair (E3M5) - Yellow key', 'The River of Fire (E2M3) - Blue key', 'The River of Fire (E2M3) - Green key', 'The River of Fire (E2M3) - Yellow key', 'The Storehouse (E3M1) - Green key', 'The Storehouse (E3M1) - Yellow key', }, - 'Levels': {'Ambulatory (E4M3)', 'Blockhouse (E4M2)', 'Catafalque (E4M1)', 'Colonnade (E5M6)', 'Courtyard (E5M4)', "D'Sparil'S Keep (E3M8)", 'Field of Judgement (E5M8)', 'Foetid Manse (E5M7)', 'Great Stair (E4M5)', 'Halls of the Apostate (E4M6)', "Hell's Maw (E1M8)", 'Hydratyr (E5M5)', 'Mausoleum (E4M9)', 'Ochre Cliffs (E5M1)', 'Quay (E5M3)', 'Ramparts of Perdition (E4M7)', 'Rapids (E5M2)', 'Sepulcher (E4M4)', 'Shattered Bridge (E4M8)', "Skein of D'Sparil (E5M9)", 'The Aquifier (E3M9)', 'The Azure Fortress (E3M4)', 'The Catacombs (E2M5)', 'The Cathedral (E1M6)', 'The Cesspool (E3M2)', 'The Chasm (E3M7)', 'The Citadel (E1M5)', 'The Confluence (E3M3)', 'The Crater (E2M1)', 'The Crypts (E1M7)', 'The Docks (E1M1)', 'The Dungeons (E1M2)', 'The Gatehouse (E1M3)', 'The Glacier (E2M9)', 'The Graveyard (E1M9)', 'The Great Hall (E2M7)', 'The Guard Tower (E1M4)', 'The Halls of Fear (E3M6)', 'The Ice Grotto (E2M4)', 'The Labyrinth (E2M6)', 'The Lava Pits (E2M2)', 'The Ophidian Lair (E3M5)', 'The Portals of Chaos (E2M8)', 'The River of Fire (E2M3)', 'The Storehouse (E3M1)', }, - 'Map Scrolls': {'Ambulatory (E4M3) - Map Scroll', 'Blockhouse (E4M2) - Map Scroll', 'Catafalque (E4M1) - Map Scroll', 'Colonnade (E5M6) - Map Scroll', 'Courtyard (E5M4) - Map Scroll', "D'Sparil'S Keep (E3M8) - Map Scroll", 'Field of Judgement (E5M8) - Map Scroll', 'Foetid Manse (E5M7) - Map Scroll', 'Great Stair (E4M5) - Map Scroll', 'Halls of the Apostate (E4M6) - Map Scroll', "Hell's Maw (E1M8) - Map Scroll", 'Hydratyr (E5M5) - Map Scroll', 'Mausoleum (E4M9) - Map Scroll', 'Ochre Cliffs (E5M1) - Map Scroll', 'Quay (E5M3) - Map Scroll', 'Ramparts of Perdition (E4M7) - Map Scroll', 'Rapids (E5M2) - Map Scroll', 'Sepulcher (E4M4) - Map Scroll', 'Shattered Bridge (E4M8) - Map Scroll', "Skein of D'Sparil (E5M9) - Map Scroll", 'The Aquifier (E3M9) - Map Scroll', 'The Azure Fortress (E3M4) - Map Scroll', 'The Catacombs (E2M5) - Map Scroll', 'The Cathedral (E1M6) - Map Scroll', 'The Cesspool (E3M2) - Map Scroll', 'The Chasm (E3M7) - Map Scroll', 'The Citadel (E1M5) - Map Scroll', 'The Confluence (E3M3) - Map Scroll', 'The Crater (E2M1) - Map Scroll', 'The Crypts (E1M7) - Map Scroll', 'The Docks (E1M1) - Map Scroll', 'The Dungeons (E1M2) - Map Scroll', 'The Gatehouse (E1M3) - Map Scroll', 'The Glacier (E2M9) - Map Scroll', 'The Graveyard (E1M9) - Map Scroll', 'The Great Hall (E2M7) - Map Scroll', 'The Guard Tower (E1M4) - Map Scroll', 'The Halls of Fear (E3M6) - Map Scroll', 'The Ice Grotto (E2M4) - Map Scroll', 'The Labyrinth (E2M6) - Map Scroll', 'The Lava Pits (E2M2) - Map Scroll', 'The Ophidian Lair (E3M5) - Map Scroll', 'The Portals of Chaos (E2M8) - Map Scroll', 'The River of Fire (E2M3) - Map Scroll', 'The Storehouse (E3M1) - Map Scroll', }, + 'Keys': {'Ambulatory (E4M3) - Blue key', 'Ambulatory (E4M3) - Green key', 'Ambulatory (E4M3) - Yellow key', 'Blockhouse (E4M2) - Blue key', 'Blockhouse (E4M2) - Green key', 'Blockhouse (E4M2) - Yellow key', 'Catafalque (E4M1) - Green key', 'Catafalque (E4M1) - Yellow key', 'Colonnade (E5M6) - Blue key', 'Colonnade (E5M6) - Green key', 'Colonnade (E5M6) - Yellow key', 'Courtyard (E5M4) - Blue key', 'Courtyard (E5M4) - Green key', 'Courtyard (E5M4) - Yellow key', 'Foetid Manse (E5M7) - Blue key', 'Foetid Manse (E5M7) - Green key', 'Foetid Manse (E5M7) - Yellow key', 'Great Stair (E4M5) - Blue key', 'Great Stair (E4M5) - Green key', 'Great Stair (E4M5) - Yellow key', 'Halls of the Apostate (E4M6) - Blue key', 'Halls of the Apostate (E4M6) - Green key', 'Halls of the Apostate (E4M6) - Yellow key', 'Hydratyr (E5M5) - Blue key', 'Hydratyr (E5M5) - Green key', 'Hydratyr (E5M5) - Yellow key', 'Mausoleum (E4M9) - Yellow key', 'Ochre Cliffs (E5M1) - Blue key', 'Ochre Cliffs (E5M1) - Green key', 'Ochre Cliffs (E5M1) - Yellow key', 'Quay (E5M3) - Blue key', 'Quay (E5M3) - Green key', 'Quay (E5M3) - Yellow key', 'Ramparts of Perdition (E4M7) - Blue key', 'Ramparts of Perdition (E4M7) - Green key', 'Ramparts of Perdition (E4M7) - Yellow key', 'Rapids (E5M2) - Green key', 'Rapids (E5M2) - Yellow key', 'Shattered Bridge (E4M8) - Yellow key', "Skein of D'Sparil (E5M9) - Blue key", "Skein of D'Sparil (E5M9) - Green key", "Skein of D'Sparil (E5M9) - Yellow key", 'The Aquifer (E3M9) - Blue key', 'The Aquifer (E3M9) - Green key', 'The Aquifer (E3M9) - Yellow key', 'The Azure Fortress (E3M4) - Green key', 'The Azure Fortress (E3M4) - Yellow key', 'The Catacombs (E2M5) - Blue key', 'The Catacombs (E2M5) - Green key', 'The Catacombs (E2M5) - Yellow key', 'The Cathedral (E1M6) - Green key', 'The Cathedral (E1M6) - Yellow key', 'The Cesspool (E3M2) - Blue key', 'The Cesspool (E3M2) - Green key', 'The Cesspool (E3M2) - Yellow key', 'The Chasm (E3M7) - Blue key', 'The Chasm (E3M7) - Green key', 'The Chasm (E3M7) - Yellow key', 'The Citadel (E1M5) - Blue key', 'The Citadel (E1M5) - Green key', 'The Citadel (E1M5) - Yellow key', 'The Confluence (E3M3) - Blue key', 'The Confluence (E3M3) - Green key', 'The Confluence (E3M3) - Yellow key', 'The Crater (E2M1) - Green key', 'The Crater (E2M1) - Yellow key', 'The Crypts (E1M7) - Blue key', 'The Crypts (E1M7) - Green key', 'The Crypts (E1M7) - Yellow key', 'The Docks (E1M1) - Yellow key', 'The Dungeons (E1M2) - Blue key', 'The Dungeons (E1M2) - Green key', 'The Dungeons (E1M2) - Yellow key', 'The Gatehouse (E1M3) - Green key', 'The Gatehouse (E1M3) - Yellow key', 'The Glacier (E2M9) - Blue key', 'The Glacier (E2M9) - Green key', 'The Glacier (E2M9) - Yellow key', 'The Graveyard (E1M9) - Blue key', 'The Graveyard (E1M9) - Green key', 'The Graveyard (E1M9) - Yellow key', 'The Great Hall (E2M7) - Blue key', 'The Great Hall (E2M7) - Green key', 'The Great Hall (E2M7) - Yellow key', 'The Guard Tower (E1M4) - Green key', 'The Guard Tower (E1M4) - Yellow key', 'The Halls of Fear (E3M6) - Blue key', 'The Halls of Fear (E3M6) - Green key', 'The Halls of Fear (E3M6) - Yellow key', 'The Ice Grotto (E2M4) - Blue key', 'The Ice Grotto (E2M4) - Green key', 'The Ice Grotto (E2M4) - Yellow key', 'The Labyrinth (E2M6) - Blue key', 'The Labyrinth (E2M6) - Green key', 'The Labyrinth (E2M6) - Yellow key', 'The Lava Pits (E2M2) - Green key', 'The Lava Pits (E2M2) - Yellow key', 'The Ophidian Lair (E3M5) - Green key', 'The Ophidian Lair (E3M5) - Yellow key', 'The River of Fire (E2M3) - Blue key', 'The River of Fire (E2M3) - Green key', 'The River of Fire (E2M3) - Yellow key', 'The Storehouse (E3M1) - Green key', 'The Storehouse (E3M1) - Yellow key', }, + 'Levels': {'Ambulatory (E4M3)', 'Blockhouse (E4M2)', 'Catafalque (E4M1)', 'Colonnade (E5M6)', 'Courtyard (E5M4)', "D'Sparil's Keep (E3M8)", 'Field of Judgement (E5M8)', 'Foetid Manse (E5M7)', 'Great Stair (E4M5)', 'Halls of the Apostate (E4M6)', "Hell's Maw (E1M8)", 'Hydratyr (E5M5)', 'Mausoleum (E4M9)', 'Ochre Cliffs (E5M1)', 'Quay (E5M3)', 'Ramparts of Perdition (E4M7)', 'Rapids (E5M2)', 'Sepulcher (E4M4)', 'Shattered Bridge (E4M8)', "Skein of D'Sparil (E5M9)", 'The Aquifer (E3M9)', 'The Azure Fortress (E3M4)', 'The Catacombs (E2M5)', 'The Cathedral (E1M6)', 'The Cesspool (E3M2)', 'The Chasm (E3M7)', 'The Citadel (E1M5)', 'The Confluence (E3M3)', 'The Crater (E2M1)', 'The Crypts (E1M7)', 'The Docks (E1M1)', 'The Dungeons (E1M2)', 'The Gatehouse (E1M3)', 'The Glacier (E2M9)', 'The Graveyard (E1M9)', 'The Great Hall (E2M7)', 'The Guard Tower (E1M4)', 'The Halls of Fear (E3M6)', 'The Ice Grotto (E2M4)', 'The Labyrinth (E2M6)', 'The Lava Pits (E2M2)', 'The Ophidian Lair (E3M5)', 'The Portals of Chaos (E2M8)', 'The River of Fire (E2M3)', 'The Storehouse (E3M1)', }, + 'Map Scrolls': {'Ambulatory (E4M3) - Map Scroll', 'Blockhouse (E4M2) - Map Scroll', 'Catafalque (E4M1) - Map Scroll', 'Colonnade (E5M6) - Map Scroll', 'Courtyard (E5M4) - Map Scroll', "D'Sparil's Keep (E3M8) - Map Scroll", 'Field of Judgement (E5M8) - Map Scroll', 'Foetid Manse (E5M7) - Map Scroll', 'Great Stair (E4M5) - Map Scroll', 'Halls of the Apostate (E4M6) - Map Scroll', "Hell's Maw (E1M8) - Map Scroll", 'Hydratyr (E5M5) - Map Scroll', 'Mausoleum (E4M9) - Map Scroll', 'Ochre Cliffs (E5M1) - Map Scroll', 'Quay (E5M3) - Map Scroll', 'Ramparts of Perdition (E4M7) - Map Scroll', 'Rapids (E5M2) - Map Scroll', 'Sepulcher (E4M4) - Map Scroll', 'Shattered Bridge (E4M8) - Map Scroll', "Skein of D'Sparil (E5M9) - Map Scroll", 'The Aquifer (E3M9) - Map Scroll', 'The Azure Fortress (E3M4) - Map Scroll', 'The Catacombs (E2M5) - Map Scroll', 'The Cathedral (E1M6) - Map Scroll', 'The Cesspool (E3M2) - Map Scroll', 'The Chasm (E3M7) - Map Scroll', 'The Citadel (E1M5) - Map Scroll', 'The Confluence (E3M3) - Map Scroll', 'The Crater (E2M1) - Map Scroll', 'The Crypts (E1M7) - Map Scroll', 'The Docks (E1M1) - Map Scroll', 'The Dungeons (E1M2) - Map Scroll', 'The Gatehouse (E1M3) - Map Scroll', 'The Glacier (E2M9) - Map Scroll', 'The Graveyard (E1M9) - Map Scroll', 'The Great Hall (E2M7) - Map Scroll', 'The Guard Tower (E1M4) - Map Scroll', 'The Halls of Fear (E3M6) - Map Scroll', 'The Ice Grotto (E2M4) - Map Scroll', 'The Labyrinth (E2M6) - Map Scroll', 'The Lava Pits (E2M2) - Map Scroll', 'The Ophidian Lair (E3M5) - Map Scroll', 'The Portals of Chaos (E2M8) - Map Scroll', 'The River of Fire (E2M3) - Map Scroll', 'The Storehouse (E3M1) - Map Scroll', }, 'Weapons': {'Dragon Claw', 'Ethereal Crossbow', 'Firemace', 'Gauntlets of the Necromancer', 'Hellstaff', 'Phoenix Rod', }, } diff --git a/worlds/heretic/Locations.py b/worlds/heretic/Locations.py index ff32df7b34..8c0814cf11 100644 --- a/worlds/heretic/Locations.py +++ b/worlds/heretic/Locations.py @@ -3633,300 +3633,300 @@ location_table: Dict[int, LocationDict] = { 'index': -1, 'doom_type': -1, 'region': "The Chasm (E3M7) Blue"}, - 371517: {'name': "D'Sparil'S Keep (E3M8) - Phoenix Rod", + 371517: {'name': "D'Sparil's Keep (E3M8) - Phoenix Rod", 'episode': 3, 'check_sanity': False, 'map': 8, 'index': 55, 'doom_type': 2003, - 'region': "D'Sparil'S Keep (E3M8) Main"}, - 371518: {'name': "D'Sparil'S Keep (E3M8) - Ethereal Crossbow", + 'region': "D'Sparil's Keep (E3M8) Main"}, + 371518: {'name': "D'Sparil's Keep (E3M8) - Ethereal Crossbow", 'episode': 3, 'check_sanity': True, 'map': 8, 'index': 56, 'doom_type': 2001, - 'region': "D'Sparil'S Keep (E3M8) Main"}, - 371519: {'name': "D'Sparil'S Keep (E3M8) - Dragon Claw", + 'region': "D'Sparil's Keep (E3M8) Main"}, + 371519: {'name': "D'Sparil's Keep (E3M8) - Dragon Claw", 'episode': 3, 'check_sanity': False, 'map': 8, 'index': 57, 'doom_type': 53, - 'region': "D'Sparil'S Keep (E3M8) Main"}, - 371520: {'name': "D'Sparil'S Keep (E3M8) - Gauntlets of the Necromancer", + 'region': "D'Sparil's Keep (E3M8) Main"}, + 371520: {'name': "D'Sparil's Keep (E3M8) - Gauntlets of the Necromancer", 'episode': 3, 'check_sanity': False, 'map': 8, 'index': 58, 'doom_type': 2005, - 'region': "D'Sparil'S Keep (E3M8) Main"}, - 371521: {'name': "D'Sparil'S Keep (E3M8) - Hellstaff", + 'region': "D'Sparil's Keep (E3M8) Main"}, + 371521: {'name': "D'Sparil's Keep (E3M8) - Hellstaff", 'episode': 3, 'check_sanity': False, 'map': 8, 'index': 59, 'doom_type': 2004, - 'region': "D'Sparil'S Keep (E3M8) Main"}, - 371522: {'name': "D'Sparil'S Keep (E3M8) - Bag of Holding", + 'region': "D'Sparil's Keep (E3M8) Main"}, + 371522: {'name': "D'Sparil's Keep (E3M8) - Bag of Holding", 'episode': 3, 'check_sanity': False, 'map': 8, 'index': 63, 'doom_type': 8, - 'region': "D'Sparil'S Keep (E3M8) Main"}, - 371523: {'name': "D'Sparil'S Keep (E3M8) - Mystic Urn", + 'region': "D'Sparil's Keep (E3M8) Main"}, + 371523: {'name': "D'Sparil's Keep (E3M8) - Mystic Urn", 'episode': 3, 'check_sanity': False, 'map': 8, 'index': 64, 'doom_type': 32, - 'region': "D'Sparil'S Keep (E3M8) Main"}, - 371524: {'name': "D'Sparil'S Keep (E3M8) - Ring of Invincibility", + 'region': "D'Sparil's Keep (E3M8) Main"}, + 371524: {'name': "D'Sparil's Keep (E3M8) - Ring of Invincibility", 'episode': 3, 'check_sanity': False, 'map': 8, 'index': 65, 'doom_type': 84, - 'region': "D'Sparil'S Keep (E3M8) Main"}, - 371525: {'name': "D'Sparil'S Keep (E3M8) - Shadowsphere", + 'region': "D'Sparil's Keep (E3M8) Main"}, + 371525: {'name': "D'Sparil's Keep (E3M8) - Shadowsphere", 'episode': 3, 'check_sanity': False, 'map': 8, 'index': 66, 'doom_type': 75, - 'region': "D'Sparil'S Keep (E3M8) Main"}, - 371526: {'name': "D'Sparil'S Keep (E3M8) - Silver Shield", + 'region': "D'Sparil's Keep (E3M8) Main"}, + 371526: {'name': "D'Sparil's Keep (E3M8) - Silver Shield", 'episode': 3, 'check_sanity': False, 'map': 8, 'index': 67, 'doom_type': 85, - 'region': "D'Sparil'S Keep (E3M8) Main"}, - 371527: {'name': "D'Sparil'S Keep (E3M8) - Enchanted Shield", + 'region': "D'Sparil's Keep (E3M8) Main"}, + 371527: {'name': "D'Sparil's Keep (E3M8) - Enchanted Shield", 'episode': 3, 'check_sanity': False, 'map': 8, 'index': 68, 'doom_type': 31, - 'region': "D'Sparil'S Keep (E3M8) Main"}, - 371528: {'name': "D'Sparil'S Keep (E3M8) - Tome of Power", + 'region': "D'Sparil's Keep (E3M8) Main"}, + 371528: {'name': "D'Sparil's Keep (E3M8) - Tome of Power", 'episode': 3, 'check_sanity': False, 'map': 8, 'index': 69, 'doom_type': 86, - 'region': "D'Sparil'S Keep (E3M8) Main"}, - 371529: {'name': "D'Sparil'S Keep (E3M8) - Tome of Power 2", + 'region': "D'Sparil's Keep (E3M8) Main"}, + 371529: {'name': "D'Sparil's Keep (E3M8) - Tome of Power 2", 'episode': 3, 'check_sanity': True, 'map': 8, 'index': 70, 'doom_type': 86, - 'region': "D'Sparil'S Keep (E3M8) Main"}, - 371530: {'name': "D'Sparil'S Keep (E3M8) - Chaos Device", + 'region': "D'Sparil's Keep (E3M8) Main"}, + 371530: {'name': "D'Sparil's Keep (E3M8) - Chaos Device", 'episode': 3, 'check_sanity': True, 'map': 8, 'index': 71, 'doom_type': 36, - 'region': "D'Sparil'S Keep (E3M8) Main"}, - 371531: {'name': "D'Sparil'S Keep (E3M8) - Tome of Power 3", + 'region': "D'Sparil's Keep (E3M8) Main"}, + 371531: {'name': "D'Sparil's Keep (E3M8) - Tome of Power 3", 'episode': 3, 'check_sanity': True, 'map': 8, 'index': 245, 'doom_type': 86, - 'region': "D'Sparil'S Keep (E3M8) Main"}, - 371532: {'name': "D'Sparil'S Keep (E3M8) - Exit", + 'region': "D'Sparil's Keep (E3M8) Main"}, + 371532: {'name': "D'Sparil's Keep (E3M8) - Exit", 'episode': 3, 'check_sanity': False, 'map': 8, 'index': -1, 'doom_type': -1, - 'region': "D'Sparil'S Keep (E3M8) Main"}, - 371533: {'name': 'The Aquifier (E3M9) - Blue key', + 'region': "D'Sparil's Keep (E3M8) Main"}, + 371533: {'name': 'The Aquifer (E3M9) - Blue key', 'episode': 3, 'check_sanity': False, 'map': 9, 'index': 12, 'doom_type': 79, - 'region': "The Aquifier (E3M9) Green"}, - 371534: {'name': 'The Aquifier (E3M9) - Green key', + 'region': "The Aquifer (E3M9) Green"}, + 371534: {'name': 'The Aquifer (E3M9) - Green key', 'episode': 3, 'check_sanity': False, 'map': 9, 'index': 13, 'doom_type': 73, - 'region': "The Aquifier (E3M9) Yellow"}, - 371535: {'name': 'The Aquifier (E3M9) - Yellow key', + 'region': "The Aquifer (E3M9) Yellow"}, + 371535: {'name': 'The Aquifer (E3M9) - Yellow key', 'episode': 3, 'check_sanity': True, 'map': 9, 'index': 14, 'doom_type': 80, - 'region': "The Aquifier (E3M9) Main"}, - 371536: {'name': 'The Aquifier (E3M9) - Ethereal Crossbow', + 'region': "The Aquifer (E3M9) Main"}, + 371536: {'name': 'The Aquifer (E3M9) - Ethereal Crossbow', 'episode': 3, 'check_sanity': False, 'map': 9, 'index': 141, 'doom_type': 2001, - 'region': "The Aquifier (E3M9) Main"}, - 371537: {'name': 'The Aquifier (E3M9) - Phoenix Rod', + 'region': "The Aquifer (E3M9) Main"}, + 371537: {'name': 'The Aquifer (E3M9) - Phoenix Rod', 'episode': 3, 'check_sanity': False, 'map': 9, 'index': 142, 'doom_type': 2003, - 'region': "The Aquifier (E3M9) Yellow"}, - 371538: {'name': 'The Aquifier (E3M9) - Dragon Claw', + 'region': "The Aquifer (E3M9) Yellow"}, + 371538: {'name': 'The Aquifer (E3M9) - Dragon Claw', 'episode': 3, 'check_sanity': False, 'map': 9, 'index': 143, 'doom_type': 53, - 'region': "The Aquifier (E3M9) Green"}, - 371539: {'name': 'The Aquifier (E3M9) - Hellstaff', + 'region': "The Aquifer (E3M9) Green"}, + 371539: {'name': 'The Aquifer (E3M9) - Hellstaff', 'episode': 3, 'check_sanity': False, 'map': 9, 'index': 144, 'doom_type': 2004, - 'region': "The Aquifier (E3M9) Green"}, - 371540: {'name': 'The Aquifier (E3M9) - Gauntlets of the Necromancer', + 'region': "The Aquifer (E3M9) Green"}, + 371540: {'name': 'The Aquifer (E3M9) - Gauntlets of the Necromancer', 'episode': 3, 'check_sanity': False, 'map': 9, 'index': 145, 'doom_type': 2005, - 'region': "The Aquifier (E3M9) Green"}, - 371541: {'name': 'The Aquifier (E3M9) - Ring of Invincibility', + 'region': "The Aquifer (E3M9) Green"}, + 371541: {'name': 'The Aquifer (E3M9) - Ring of Invincibility', 'episode': 3, 'check_sanity': False, 'map': 9, 'index': 148, 'doom_type': 84, - 'region': "The Aquifier (E3M9) Yellow"}, - 371542: {'name': 'The Aquifier (E3M9) - Mystic Urn', + 'region': "The Aquifer (E3M9) Yellow"}, + 371542: {'name': 'The Aquifer (E3M9) - Mystic Urn', 'episode': 3, 'check_sanity': False, 'map': 9, 'index': 149, 'doom_type': 32, - 'region': "The Aquifier (E3M9) Green"}, - 371543: {'name': 'The Aquifier (E3M9) - Silver Shield', + 'region': "The Aquifer (E3M9) Green"}, + 371543: {'name': 'The Aquifer (E3M9) - Silver Shield', 'episode': 3, 'check_sanity': False, 'map': 9, 'index': 151, 'doom_type': 85, - 'region': "The Aquifier (E3M9) Main"}, - 371544: {'name': 'The Aquifier (E3M9) - Tome of Power', + 'region': "The Aquifer (E3M9) Main"}, + 371544: {'name': 'The Aquifer (E3M9) - Tome of Power', 'episode': 3, 'check_sanity': False, 'map': 9, 'index': 152, 'doom_type': 86, - 'region': "The Aquifier (E3M9) Main"}, - 371545: {'name': 'The Aquifier (E3M9) - Bag of Holding', + 'region': "The Aquifer (E3M9) Main"}, + 371545: {'name': 'The Aquifer (E3M9) - Bag of Holding', 'episode': 3, 'check_sanity': False, 'map': 9, 'index': 153, 'doom_type': 8, - 'region': "The Aquifier (E3M9) Yellow"}, - 371546: {'name': 'The Aquifier (E3M9) - Morph Ovum', + 'region': "The Aquifer (E3M9) Yellow"}, + 371546: {'name': 'The Aquifer (E3M9) - Morph Ovum', 'episode': 3, 'check_sanity': False, 'map': 9, 'index': 154, 'doom_type': 30, - 'region': "The Aquifier (E3M9) Green"}, - 371547: {'name': 'The Aquifier (E3M9) - Map Scroll', + 'region': "The Aquifer (E3M9) Green"}, + 371547: {'name': 'The Aquifer (E3M9) - Map Scroll', 'episode': 3, 'check_sanity': True, 'map': 9, 'index': 155, 'doom_type': 35, - 'region': "The Aquifier (E3M9) Green"}, - 371548: {'name': 'The Aquifier (E3M9) - Chaos Device', + 'region': "The Aquifer (E3M9) Green"}, + 371548: {'name': 'The Aquifer (E3M9) - Chaos Device', 'episode': 3, 'check_sanity': False, 'map': 9, 'index': 156, 'doom_type': 36, - 'region': "The Aquifier (E3M9) Yellow"}, - 371549: {'name': 'The Aquifier (E3M9) - Enchanted Shield', + 'region': "The Aquifer (E3M9) Yellow"}, + 371549: {'name': 'The Aquifer (E3M9) - Enchanted Shield', 'episode': 3, 'check_sanity': False, 'map': 9, 'index': 157, 'doom_type': 31, - 'region': "The Aquifier (E3M9) Green"}, - 371550: {'name': 'The Aquifier (E3M9) - Tome of Power 2', + 'region': "The Aquifer (E3M9) Green"}, + 371550: {'name': 'The Aquifer (E3M9) - Tome of Power 2', 'episode': 3, 'check_sanity': False, 'map': 9, 'index': 158, 'doom_type': 86, - 'region': "The Aquifier (E3M9) Green"}, - 371551: {'name': 'The Aquifier (E3M9) - Torch', + 'region': "The Aquifer (E3M9) Green"}, + 371551: {'name': 'The Aquifer (E3M9) - Torch', 'episode': 3, 'check_sanity': False, 'map': 9, 'index': 159, 'doom_type': 33, - 'region': "The Aquifier (E3M9) Main"}, - 371552: {'name': 'The Aquifier (E3M9) - Shadowsphere', + 'region': "The Aquifer (E3M9) Main"}, + 371552: {'name': 'The Aquifer (E3M9) - Shadowsphere', 'episode': 3, 'check_sanity': False, 'map': 9, 'index': 160, 'doom_type': 75, - 'region': "The Aquifier (E3M9) Green"}, - 371553: {'name': 'The Aquifier (E3M9) - Silver Shield 2', + 'region': "The Aquifer (E3M9) Green"}, + 371553: {'name': 'The Aquifer (E3M9) - Silver Shield 2', 'episode': 3, 'check_sanity': True, 'map': 9, 'index': 374, 'doom_type': 85, - 'region': "The Aquifier (E3M9) Green"}, - 371554: {'name': 'The Aquifier (E3M9) - Firemace', + 'region': "The Aquifer (E3M9) Green"}, + 371554: {'name': 'The Aquifer (E3M9) - Firemace', 'episode': 3, 'check_sanity': False, 'map': 9, 'index': 478, 'doom_type': 2002, - 'region': "The Aquifier (E3M9) Green"}, - 371555: {'name': 'The Aquifier (E3M9) - Firemace 2', + 'region': "The Aquifer (E3M9) Green"}, + 371555: {'name': 'The Aquifer (E3M9) - Firemace 2', 'episode': 3, 'check_sanity': False, 'map': 9, 'index': 526, 'doom_type': 2002, - 'region': "The Aquifier (E3M9) Green"}, - 371556: {'name': 'The Aquifier (E3M9) - Firemace 3', + 'region': "The Aquifer (E3M9) Green"}, + 371556: {'name': 'The Aquifer (E3M9) - Firemace 3', 'episode': 3, 'check_sanity': False, 'map': 9, 'index': 527, 'doom_type': 2002, - 'region': "The Aquifier (E3M9) Green"}, - 371557: {'name': 'The Aquifier (E3M9) - Firemace 4', + 'region': "The Aquifer (E3M9) Green"}, + 371557: {'name': 'The Aquifer (E3M9) - Firemace 4', 'episode': 3, 'check_sanity': True, 'map': 9, 'index': 528, 'doom_type': 2002, - 'region': "The Aquifier (E3M9) Yellow"}, - 371558: {'name': 'The Aquifier (E3M9) - Exit', + 'region': "The Aquifer (E3M9) Yellow"}, + 371558: {'name': 'The Aquifer (E3M9) - Exit', 'episode': 3, 'check_sanity': False, 'map': 9, 'index': -1, 'doom_type': -1, - 'region': "The Aquifier (E3M9) Blue"}, + 'region': "The Aquifer (E3M9) Blue"}, 371559: {'name': 'Catafalque (E4M1) - Yellow key', 'episode': 4, 'check_sanity': False, @@ -5963,7 +5963,7 @@ location_table: Dict[int, LocationDict] = { 'map': 3, 'index': 213, 'doom_type': 2005, - 'region': "Quay (E5M3) Main"}, + 'region': "Quay (E5M3) Blue"}, 371850: {'name': 'Quay (E5M3) - Dragon Claw', 'episode': 5, 'check_sanity': False, @@ -6145,7 +6145,7 @@ location_table: Dict[int, LocationDict] = { 'map': 4, 'index': 3, 'doom_type': 79, - 'region': "Courtyard (E5M4) Main"}, + 'region': "Courtyard (E5M4) Green"}, 371876: {'name': 'Courtyard (E5M4) - Yellow key', 'episode': 5, 'check_sanity': False, @@ -6159,7 +6159,7 @@ location_table: Dict[int, LocationDict] = { 'map': 4, 'index': 21, 'doom_type': 73, - 'region': "Courtyard (E5M4) Kakis"}, + 'region': "Courtyard (E5M4) Yellow"}, 371878: {'name': 'Courtyard (E5M4) - Gauntlets of the Necromancer', 'episode': 5, 'check_sanity': False, @@ -6187,14 +6187,14 @@ location_table: Dict[int, LocationDict] = { 'map': 4, 'index': 87, 'doom_type': 2004, - 'region': "Courtyard (E5M4) Kakis"}, + 'region': "Courtyard (E5M4) Yellow"}, 371882: {'name': 'Courtyard (E5M4) - Phoenix Rod', 'episode': 5, 'check_sanity': False, 'map': 4, 'index': 88, 'doom_type': 2003, - 'region': "Courtyard (E5M4) Main"}, + 'region': "Courtyard (E5M4) Green"}, 371883: {'name': 'Courtyard (E5M4) - Morph Ovum', 'episode': 5, 'check_sanity': False, @@ -6229,7 +6229,7 @@ location_table: Dict[int, LocationDict] = { 'map': 4, 'index': 104, 'doom_type': 84, - 'region': "Courtyard (E5M4) Kakis"}, + 'region': "Courtyard (E5M4) Yellow"}, 371888: {'name': 'Courtyard (E5M4) - Shadowsphere', 'episode': 5, 'check_sanity': False, @@ -6250,14 +6250,14 @@ location_table: Dict[int, LocationDict] = { 'map': 4, 'index': 107, 'doom_type': 35, - 'region': "Courtyard (E5M4) Kakis"}, + 'region': "Courtyard (E5M4) Yellow"}, 371891: {'name': 'Courtyard (E5M4) - Chaos Device', 'episode': 5, 'check_sanity': False, 'map': 4, 'index': 108, 'doom_type': 36, - 'region': "Courtyard (E5M4) Main"}, + 'region': "Courtyard (E5M4) Green"}, 371892: {'name': 'Courtyard (E5M4) - Tome of Power', 'episode': 5, 'check_sanity': False, @@ -6278,7 +6278,7 @@ location_table: Dict[int, LocationDict] = { 'map': 4, 'index': 111, 'doom_type': 86, - 'region': "Courtyard (E5M4) Kakis"}, + 'region': "Courtyard (E5M4) Yellow"}, 371895: {'name': 'Courtyard (E5M4) - Torch', 'episode': 5, 'check_sanity': False, @@ -6299,7 +6299,7 @@ location_table: Dict[int, LocationDict] = { 'map': 4, 'index': 219, 'doom_type': 85, - 'region': "Courtyard (E5M4) Kakis"}, + 'region': "Courtyard (E5M4) Yellow"}, 371898: {'name': 'Courtyard (E5M4) - Bag of Holding 3', 'episode': 5, 'check_sanity': False, @@ -7247,23 +7247,23 @@ location_name_groups: Dict[str, Set[str]] = { 'Courtyard (E5M4) - Torch', 'Courtyard (E5M4) - Yellow key', }, - "D'Sparil'S Keep (E3M8)": { - "D'Sparil'S Keep (E3M8) - Bag of Holding", - "D'Sparil'S Keep (E3M8) - Chaos Device", - "D'Sparil'S Keep (E3M8) - Dragon Claw", - "D'Sparil'S Keep (E3M8) - Enchanted Shield", - "D'Sparil'S Keep (E3M8) - Ethereal Crossbow", - "D'Sparil'S Keep (E3M8) - Exit", - "D'Sparil'S Keep (E3M8) - Gauntlets of the Necromancer", - "D'Sparil'S Keep (E3M8) - Hellstaff", - "D'Sparil'S Keep (E3M8) - Mystic Urn", - "D'Sparil'S Keep (E3M8) - Phoenix Rod", - "D'Sparil'S Keep (E3M8) - Ring of Invincibility", - "D'Sparil'S Keep (E3M8) - Shadowsphere", - "D'Sparil'S Keep (E3M8) - Silver Shield", - "D'Sparil'S Keep (E3M8) - Tome of Power", - "D'Sparil'S Keep (E3M8) - Tome of Power 2", - "D'Sparil'S Keep (E3M8) - Tome of Power 3", + "D'Sparil's Keep (E3M8)": { + "D'Sparil's Keep (E3M8) - Bag of Holding", + "D'Sparil's Keep (E3M8) - Chaos Device", + "D'Sparil's Keep (E3M8) - Dragon Claw", + "D'Sparil's Keep (E3M8) - Enchanted Shield", + "D'Sparil's Keep (E3M8) - Ethereal Crossbow", + "D'Sparil's Keep (E3M8) - Exit", + "D'Sparil's Keep (E3M8) - Gauntlets of the Necromancer", + "D'Sparil's Keep (E3M8) - Hellstaff", + "D'Sparil's Keep (E3M8) - Mystic Urn", + "D'Sparil's Keep (E3M8) - Phoenix Rod", + "D'Sparil's Keep (E3M8) - Ring of Invincibility", + "D'Sparil's Keep (E3M8) - Shadowsphere", + "D'Sparil's Keep (E3M8) - Silver Shield", + "D'Sparil's Keep (E3M8) - Tome of Power", + "D'Sparil's Keep (E3M8) - Tome of Power 2", + "D'Sparil's Keep (E3M8) - Tome of Power 3", }, 'Field of Judgement (E5M8)': { 'Field of Judgement (E5M8) - Bag of Holding', @@ -7641,33 +7641,33 @@ location_name_groups: Dict[str, Set[str]] = { "Skein of D'Sparil (E5M9) - Torch", "Skein of D'Sparil (E5M9) - Yellow key", }, - 'The Aquifier (E3M9)': { - 'The Aquifier (E3M9) - Bag of Holding', - 'The Aquifier (E3M9) - Blue key', - 'The Aquifier (E3M9) - Chaos Device', - 'The Aquifier (E3M9) - Dragon Claw', - 'The Aquifier (E3M9) - Enchanted Shield', - 'The Aquifier (E3M9) - Ethereal Crossbow', - 'The Aquifier (E3M9) - Exit', - 'The Aquifier (E3M9) - Firemace', - 'The Aquifier (E3M9) - Firemace 2', - 'The Aquifier (E3M9) - Firemace 3', - 'The Aquifier (E3M9) - Firemace 4', - 'The Aquifier (E3M9) - Gauntlets of the Necromancer', - 'The Aquifier (E3M9) - Green key', - 'The Aquifier (E3M9) - Hellstaff', - 'The Aquifier (E3M9) - Map Scroll', - 'The Aquifier (E3M9) - Morph Ovum', - 'The Aquifier (E3M9) - Mystic Urn', - 'The Aquifier (E3M9) - Phoenix Rod', - 'The Aquifier (E3M9) - Ring of Invincibility', - 'The Aquifier (E3M9) - Shadowsphere', - 'The Aquifier (E3M9) - Silver Shield', - 'The Aquifier (E3M9) - Silver Shield 2', - 'The Aquifier (E3M9) - Tome of Power', - 'The Aquifier (E3M9) - Tome of Power 2', - 'The Aquifier (E3M9) - Torch', - 'The Aquifier (E3M9) - Yellow key', + 'The Aquifer (E3M9)': { + 'The Aquifer (E3M9) - Bag of Holding', + 'The Aquifer (E3M9) - Blue key', + 'The Aquifer (E3M9) - Chaos Device', + 'The Aquifer (E3M9) - Dragon Claw', + 'The Aquifer (E3M9) - Enchanted Shield', + 'The Aquifer (E3M9) - Ethereal Crossbow', + 'The Aquifer (E3M9) - Exit', + 'The Aquifer (E3M9) - Firemace', + 'The Aquifer (E3M9) - Firemace 2', + 'The Aquifer (E3M9) - Firemace 3', + 'The Aquifer (E3M9) - Firemace 4', + 'The Aquifer (E3M9) - Gauntlets of the Necromancer', + 'The Aquifer (E3M9) - Green key', + 'The Aquifer (E3M9) - Hellstaff', + 'The Aquifer (E3M9) - Map Scroll', + 'The Aquifer (E3M9) - Morph Ovum', + 'The Aquifer (E3M9) - Mystic Urn', + 'The Aquifer (E3M9) - Phoenix Rod', + 'The Aquifer (E3M9) - Ring of Invincibility', + 'The Aquifer (E3M9) - Shadowsphere', + 'The Aquifer (E3M9) - Silver Shield', + 'The Aquifer (E3M9) - Silver Shield 2', + 'The Aquifer (E3M9) - Tome of Power', + 'The Aquifer (E3M9) - Tome of Power 2', + 'The Aquifer (E3M9) - Torch', + 'The Aquifer (E3M9) - Yellow key', }, 'The Azure Fortress (E3M4)': { 'The Azure Fortress (E3M4) - Bag of Holding', diff --git a/worlds/heretic/Maps.py b/worlds/heretic/Maps.py index 716de29041..6e9b3df246 100644 --- a/worlds/heretic/Maps.py +++ b/worlds/heretic/Maps.py @@ -29,8 +29,8 @@ map_names: List[str] = [ 'The Ophidian Lair (E3M5)', 'The Halls of Fear (E3M6)', 'The Chasm (E3M7)', - "D'Sparil'S Keep (E3M8)", - 'The Aquifier (E3M9)', + "D'Sparil's Keep (E3M8)", + 'The Aquifer (E3M9)', 'Catafalque (E4M1)', 'Blockhouse (E4M2)', 'Ambulatory (E4M3)', diff --git a/worlds/heretic/Regions.py b/worlds/heretic/Regions.py index 81a4c9ce49..1d35db5e7a 100644 --- a/worlds/heretic/Regions.py +++ b/worlds/heretic/Regions.py @@ -520,34 +520,34 @@ regions:List[RegionDict] = [ "episode":3, "connections":[{"target":"The Chasm (E3M7) Yellow","pro":False}]}, - # D'Sparil'S Keep (E3M8) - {"name":"D'Sparil'S Keep (E3M8) Main", + # D'Sparil's Keep (E3M8) + {"name":"D'Sparil's Keep (E3M8) Main", "connects_to_hub":True, "episode":3, "connections":[]}, - # The Aquifier (E3M9) - {"name":"The Aquifier (E3M9) Main", + # The Aquifer (E3M9) + {"name":"The Aquifer (E3M9) Main", "connects_to_hub":True, "episode":3, - "connections":[{"target":"The Aquifier (E3M9) Yellow","pro":False}]}, - {"name":"The Aquifier (E3M9) Blue", + "connections":[{"target":"The Aquifer (E3M9) Yellow","pro":False}]}, + {"name":"The Aquifer (E3M9) Blue", "connects_to_hub":False, "episode":3, "connections":[]}, - {"name":"The Aquifier (E3M9) Yellow", + {"name":"The Aquifer (E3M9) Yellow", "connects_to_hub":False, "episode":3, "connections":[ - {"target":"The Aquifier (E3M9) Green","pro":False}, - {"target":"The Aquifier (E3M9) Main","pro":False}]}, - {"name":"The Aquifier (E3M9) Green", + {"target":"The Aquifer (E3M9) Green","pro":False}, + {"target":"The Aquifer (E3M9) Main","pro":False}]}, + {"name":"The Aquifer (E3M9) Green", "connects_to_hub":False, "episode":3, "connections":[ - {"target":"The Aquifier (E3M9) Yellow","pro":False}, - {"target":"The Aquifier (E3M9) Main","pro":False}, - {"target":"The Aquifier (E3M9) Blue","pro":False}]}, + {"target":"The Aquifer (E3M9) Yellow","pro":False}, + {"target":"The Aquifer (E3M9) Main","pro":False}, + {"target":"The Aquifer (E3M9) Blue","pro":False}]}, # Catafalque (E4M1) {"name":"Catafalque (E4M1) Main", @@ -795,16 +795,22 @@ regions:List[RegionDict] = [ "connects_to_hub":True, "episode":5, "connections":[ - {"target":"Courtyard (E5M4) Kakis","pro":False}, + {"target":"Courtyard (E5M4) Yellow","pro":False}, {"target":"Courtyard (E5M4) Blue","pro":False}]}, {"name":"Courtyard (E5M4) Blue", "connects_to_hub":False, "episode":5, "connections":[{"target":"Courtyard (E5M4) Main","pro":False}]}, - {"name":"Courtyard (E5M4) Kakis", + {"name":"Courtyard (E5M4) Yellow", "connects_to_hub":False, "episode":5, - "connections":[{"target":"Courtyard (E5M4) Main","pro":False}]}, + "connections":[ + {"target":"Courtyard (E5M4) Main","pro":False}, + {"target":"Courtyard (E5M4) Green","pro":False}]}, + {"name":"Courtyard (E5M4) Green", + "connects_to_hub":False, + "episode":5, + "connections":[{"target":"Courtyard (E5M4) Yellow","pro":False}]}, # Hydratyr (E5M5) {"name":"Hydratyr (E5M5) Main", diff --git a/worlds/heretic/Rules.py b/worlds/heretic/Rules.py index 492b8f38c6..0fdf21252a 100644 --- a/worlds/heretic/Rules.py +++ b/worlds/heretic/Rules.py @@ -388,9 +388,9 @@ def set_episode3_rules(player, multiworld, pro): set_rule(multiworld.get_entrance("The Chasm (E3M7) Green -> The Chasm (E3M7) Yellow", player), lambda state: state.has("The Chasm (E3M7) - Green key", player, 1)) - # D'Sparil'S Keep (E3M8) - set_rule(multiworld.get_entrance("Hub -> D'Sparil'S Keep (E3M8) Main", player), lambda state: - state.has("D'Sparil'S Keep (E3M8)", player, 1) and + # D'Sparil's Keep (E3M8) + set_rule(multiworld.get_entrance("Hub -> D'Sparil's Keep (E3M8) Main", player), lambda state: + state.has("D'Sparil's Keep (E3M8)", player, 1) and state.has("Gauntlets of the Necromancer", player, 1) and state.has("Ethereal Crossbow", player, 1) and state.has("Dragon Claw", player, 1) and @@ -398,23 +398,23 @@ def set_episode3_rules(player, multiworld, pro): state.has("Firemace", player, 1) and state.has("Hellstaff", player, 1)) - # The Aquifier (E3M9) - set_rule(multiworld.get_entrance("Hub -> The Aquifier (E3M9) Main", player), lambda state: - state.has("The Aquifier (E3M9)", player, 1) and + # The Aquifer (E3M9) + set_rule(multiworld.get_entrance("Hub -> The Aquifer (E3M9) Main", player), lambda state: + state.has("The Aquifer (E3M9)", player, 1) and state.has("Gauntlets of the Necromancer", player, 1) and state.has("Ethereal Crossbow", player, 1) and state.has("Dragon Claw", player, 1) and state.has("Phoenix Rod", player, 1) and state.has("Firemace", player, 1) and state.has("Hellstaff", player, 1)) - set_rule(multiworld.get_entrance("The Aquifier (E3M9) Main -> The Aquifier (E3M9) Yellow", player), lambda state: - state.has("The Aquifier (E3M9) - Yellow key", player, 1)) - set_rule(multiworld.get_entrance("The Aquifier (E3M9) Yellow -> The Aquifier (E3M9) Green", player), lambda state: - state.has("The Aquifier (E3M9) - Green key", player, 1)) - set_rule(multiworld.get_entrance("The Aquifier (E3M9) Yellow -> The Aquifier (E3M9) Main", player), lambda state: - state.has("The Aquifier (E3M9) - Yellow key", player, 1)) - set_rule(multiworld.get_entrance("The Aquifier (E3M9) Green -> The Aquifier (E3M9) Yellow", player), lambda state: - state.has("The Aquifier (E3M9) - Green key", player, 1)) + set_rule(multiworld.get_entrance("The Aquifer (E3M9) Main -> The Aquifer (E3M9) Yellow", player), lambda state: + state.has("The Aquifer (E3M9) - Yellow key", player, 1)) + set_rule(multiworld.get_entrance("The Aquifer (E3M9) Yellow -> The Aquifer (E3M9) Green", player), lambda state: + state.has("The Aquifer (E3M9) - Green key", player, 1)) + set_rule(multiworld.get_entrance("The Aquifer (E3M9) Yellow -> The Aquifer (E3M9) Main", player), lambda state: + state.has("The Aquifer (E3M9) - Yellow key", player, 1)) + set_rule(multiworld.get_entrance("The Aquifer (E3M9) Green -> The Aquifer (E3M9) Yellow", player), lambda state: + state.has("The Aquifer (E3M9) - Green key", player, 1)) def set_episode4_rules(player, multiworld, pro): @@ -623,15 +623,17 @@ def set_episode5_rules(player, multiworld, pro): (state.has("Phoenix Rod", player, 1) or state.has("Firemace", player, 1) or state.has("Hellstaff", player, 1))) - set_rule(multiworld.get_entrance("Courtyard (E5M4) Main -> Courtyard (E5M4) Kakis", player), lambda state: - state.has("Courtyard (E5M4) - Yellow key", player, 1) or - state.has("Courtyard (E5M4) - Green key", player, 1)) + set_rule(multiworld.get_entrance("Courtyard (E5M4) Main -> Courtyard (E5M4) Yellow", player), lambda state: + state.has("Courtyard (E5M4) - Yellow key", player, 1)) set_rule(multiworld.get_entrance("Courtyard (E5M4) Main -> Courtyard (E5M4) Blue", player), lambda state: state.has("Courtyard (E5M4) - Blue key", player, 1)) set_rule(multiworld.get_entrance("Courtyard (E5M4) Blue -> Courtyard (E5M4) Main", player), lambda state: state.has("Courtyard (E5M4) - Blue key", player, 1)) - set_rule(multiworld.get_entrance("Courtyard (E5M4) Kakis -> Courtyard (E5M4) Main", player), lambda state: - state.has("Courtyard (E5M4) - Yellow key", player, 1) or + set_rule(multiworld.get_entrance("Courtyard (E5M4) Yellow -> Courtyard (E5M4) Main", player), lambda state: + state.has("Courtyard (E5M4) - Yellow key", player, 1)) + set_rule(multiworld.get_entrance("Courtyard (E5M4) Yellow -> Courtyard (E5M4) Green", player), lambda state: + state.has("Courtyard (E5M4) - Green key", player, 1)) + set_rule(multiworld.get_entrance("Courtyard (E5M4) Green -> Courtyard (E5M4) Yellow", player), lambda state: state.has("Courtyard (E5M4) - Green key", player, 1)) # Hydratyr (E5M5) diff --git a/worlds/heretic/__init__.py b/worlds/heretic/__init__.py index 14b7ca49fe..c2358e39d8 100644 --- a/worlds/heretic/__init__.py +++ b/worlds/heretic/__init__.py @@ -49,18 +49,18 @@ class HereticWorld(World): location_name_to_id = {data["name"]: loc_id for loc_id, data in Locations.location_table.items()} location_name_groups = Locations.location_name_groups - starting_level_for_episode: List[str] = [ - "The Docks (E1M1)", - "The Crater (E2M1)", - "The Storehouse (E3M1)", - "Catafalque (E4M1)", - "Ochre Cliffs (E5M1)" - ] + starting_level_for_episode: Dict[int, str] = { + 1: "The Docks (E1M1)", + 2: "The Crater (E2M1)", + 3: "The Storehouse (E3M1)", + 4: "Catafalque (E4M1)", + 5: "Ochre Cliffs (E5M1)" + } - boss_level_for_episode: List[str] = [ + all_boss_levels: List[str] = [ "Hell's Maw (E1M8)", "The Portals of Chaos (E2M8)", - "D'Sparil'S Keep (E3M8)", + "D'Sparil's Keep (E3M8)", "Shattered Bridge (E4M8)", "Field of Judgement (E5M8)" ] @@ -82,6 +82,7 @@ class HereticWorld(World): def __init__(self, multiworld: MultiWorld, player: int): self.included_episodes = [1, 1, 1, 0, 0] self.location_count = 0 + self.starting_levels = [] super().__init__(multiworld, player) @@ -100,6 +101,14 @@ class HereticWorld(World): if self.get_episode_count() == 0: self.included_episodes[0] = 1 + self.starting_levels = [level_name for (episode, level_name) in self.starting_level_for_episode.items() + if self.included_episodes[episode - 1]] + + # For Solo Episode 1, place the Yellow Key for E1M1 early. + # Gives the generator five potential placements (plus the forced key) instead of only two. + if self.get_episode_count() == 1 and self.included_episodes[0]: + self.multiworld.early_items[self.player]["The Docks (E1M1) - Yellow key"] = 1 + def create_regions(self): pro = self.options.pro.value check_sanity = self.options.check_sanity.value @@ -154,7 +163,7 @@ class HereticWorld(World): def completion_rule(self, state: CollectionState): goal_levels = Maps.map_names if self.options.goal.value: - goal_levels = self.boss_level_for_episode + goal_levels = self.all_boss_levels for map_name in goal_levels: if map_name + " - Exit" not in self.location_name_to_id: @@ -203,7 +212,7 @@ class HereticWorld(World): if item["episode"] != -1 and not self.included_episodes[item["episode"] - 1]: continue - count = item["count"] if item["name"] not in self.starting_level_for_episode else item["count"] - 1 + count = item["count"] if item["name"] not in self.starting_levels else item["count"] - 1 itempool += [self.create_item(item["name"]) for _ in range(count)] # Bag(s) of Holding based on options @@ -236,9 +245,8 @@ class HereticWorld(World): self.location_count -= 1 # Give starting levels right away - for i in range(len(self.included_episodes)): - if self.included_episodes[i]: - self.multiworld.push_precollected(self.create_item(self.starting_level_for_episode[i])) + for map_name in self.starting_levels: + self.multiworld.push_precollected(self.create_item(map_name)) # Give Computer area maps if option selected if self.options.start_with_map_scrolls.value: From a3666f2ae58aa46f22bb84c2f24a6b374ecb40a3 Mon Sep 17 00:00:00 2001 From: PoryGone <98504756+PoryGone@users.noreply.github.com> Date: Sun, 30 Mar 2025 16:19:24 -0400 Subject: [PATCH 11/53] SA2B: Fix critical typo #4779 --- worlds/sa2b/Rules.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/worlds/sa2b/Rules.py b/worlds/sa2b/Rules.py index 718c5fe045..53edc686b6 100644 --- a/worlds/sa2b/Rules.py +++ b/worlds/sa2b/Rules.py @@ -1747,7 +1747,7 @@ def set_mission_upgrade_rules_standard(multiworld: MultiWorld, world: World, pla state.has(ItemName.sonic_bounce_bracelet, player) and state.has(ItemName.sonic_flame_ring, player))) - add_rule(multiworld.get_location(LocationName.eqq_quarters_itembox_9, player), + add_rule(multiworld.get_location(LocationName.egg_quarters_itembox_9, player), lambda state: state.has(ItemName.rouge_mystic_melody, player)) add_rule(multiworld.get_location(LocationName.lost_colony_itembox_9, player), lambda state: state.has(ItemName.eggman_jet_engine, player)) @@ -3214,7 +3214,7 @@ def set_mission_upgrade_rules_hard(multiworld: MultiWorld, world: World, player: lambda state: (state.has(ItemName.sonic_light_shoes, player) and state.has(ItemName.sonic_flame_ring, player))) - add_rule(multiworld.get_location(LocationName.eqq_quarters_itembox_9, player), + add_rule(multiworld.get_location(LocationName.egg_quarters_itembox_9, player), lambda state: state.has(ItemName.rouge_mystic_melody, player)) add_rule(multiworld.get_location(LocationName.lost_colony_itembox_9, player), lambda state: state.has(ItemName.eggman_jet_engine, player)) From 4a3d23e0e67e5d339b4d4281ee195464195c3439 Mon Sep 17 00:00:00 2001 From: Fabian Dill Date: Tue, 1 Apr 2025 16:29:32 +0200 Subject: [PATCH 12/53] Core: update cx-Freeze to 8.0.0 & Worlds: fix packages missing __init__.py (#4773) --- setup.py | 2 +- test/general/test_packages.py | 14 ++++++++++++++ worlds/civ_6/data/__init__.py | 0 worlds/cv64/data/__init__.py | 0 worlds/cvcotm/data/__init__.py | 0 worlds/ffmq/data/__init__.py | 0 worlds/kh2/Names/__init__.py | 0 worlds/ladx/LADXR/__init__.py | 0 worlds/ladx/LADXR/locations/__init__.py | 0 worlds/ladx/LADXR/mapgen/locations/__init__.py | 0 worlds/ladx/LADXR/mapgen/roomtype/__init__.py | 0 worlds/ladx/LADXR/patches/__init__.py | 0 worlds/landstalker/data/__init__.py | 0 worlds/mlss/Names/__init__.py | 0 worlds/mmbn3/Names/__init__.py | 0 worlds/osrs/LogicCSV/__init__.py | 0 worlds/smw/Names/__init__.py | 0 worlds/tww/randomizers/__init__.py | 0 worlds/witness/data/settings/__init__.py | 0 19 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 test/general/test_packages.py create mode 100644 worlds/civ_6/data/__init__.py create mode 100644 worlds/cv64/data/__init__.py create mode 100644 worlds/cvcotm/data/__init__.py create mode 100644 worlds/ffmq/data/__init__.py create mode 100644 worlds/kh2/Names/__init__.py create mode 100644 worlds/ladx/LADXR/__init__.py create mode 100644 worlds/ladx/LADXR/locations/__init__.py create mode 100644 worlds/ladx/LADXR/mapgen/locations/__init__.py create mode 100644 worlds/ladx/LADXR/mapgen/roomtype/__init__.py create mode 100644 worlds/ladx/LADXR/patches/__init__.py create mode 100644 worlds/landstalker/data/__init__.py create mode 100644 worlds/mlss/Names/__init__.py create mode 100644 worlds/mmbn3/Names/__init__.py create mode 100644 worlds/osrs/LogicCSV/__init__.py create mode 100644 worlds/smw/Names/__init__.py create mode 100644 worlds/tww/randomizers/__init__.py create mode 100644 worlds/witness/data/settings/__init__.py diff --git a/setup.py b/setup.py index 59c2d698d3..266ea9874a 100644 --- a/setup.py +++ b/setup.py @@ -19,7 +19,7 @@ from typing import Dict, Iterable, List, Optional, Sequence, Set, Tuple, Union # This is a bit jank. We need cx-Freeze to be able to run anything from this script, so install it -requirement = 'cx-Freeze==7.2.0' +requirement = 'cx-Freeze==8.0.0' try: import pkg_resources try: diff --git a/test/general/test_packages.py b/test/general/test_packages.py new file mode 100644 index 0000000000..32c7bdf47e --- /dev/null +++ b/test/general/test_packages.py @@ -0,0 +1,14 @@ +import unittest +import os + + +class TestPackages(unittest.TestCase): + def test_packages_have_init(self): + """Test that all world folders containing .py files also have a __init__.py file, + to indicate full package rather than namespace package.""" + import Utils + + worlds_path = Utils.local_path("worlds") + for dirpath, dirnames, filenames in os.walk(worlds_path): + with self.subTest(directory=dirpath): + self.assertEqual("__init__.py" in filenames, any(file.endswith(".py") for file in filenames)) diff --git a/worlds/civ_6/data/__init__.py b/worlds/civ_6/data/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/worlds/cv64/data/__init__.py b/worlds/cv64/data/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/worlds/cvcotm/data/__init__.py b/worlds/cvcotm/data/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/worlds/ffmq/data/__init__.py b/worlds/ffmq/data/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/worlds/kh2/Names/__init__.py b/worlds/kh2/Names/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/worlds/ladx/LADXR/__init__.py b/worlds/ladx/LADXR/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/worlds/ladx/LADXR/locations/__init__.py b/worlds/ladx/LADXR/locations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/worlds/ladx/LADXR/mapgen/locations/__init__.py b/worlds/ladx/LADXR/mapgen/locations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/worlds/ladx/LADXR/mapgen/roomtype/__init__.py b/worlds/ladx/LADXR/mapgen/roomtype/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/worlds/ladx/LADXR/patches/__init__.py b/worlds/ladx/LADXR/patches/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/worlds/landstalker/data/__init__.py b/worlds/landstalker/data/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/worlds/mlss/Names/__init__.py b/worlds/mlss/Names/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/worlds/mmbn3/Names/__init__.py b/worlds/mmbn3/Names/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/worlds/osrs/LogicCSV/__init__.py b/worlds/osrs/LogicCSV/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/worlds/smw/Names/__init__.py b/worlds/smw/Names/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/worlds/tww/randomizers/__init__.py b/worlds/tww/randomizers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/worlds/witness/data/settings/__init__.py b/worlds/witness/data/settings/__init__.py new file mode 100644 index 0000000000..e69de29bb2 From c9d8a8661caca346a2e0a9e069e7e701fca3e372 Mon Sep 17 00:00:00 2001 From: Silvris <58583688+Silvris@users.noreply.github.com> Date: Tue, 1 Apr 2025 11:06:49 -0500 Subject: [PATCH 13/53] kvui: Fix hint tab formatting regression (#4778) Co-authored-by: Fabian Dill --- kvui.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/kvui.py b/kvui.py index 60042b00ec..3616207298 100644 --- a/kvui.py +++ b/kvui.py @@ -817,6 +817,12 @@ class HintLayout(BoxLayout): boxlayout.add_widget(AutocompleteHintInput()) self.add_widget(boxlayout) + def fix_heights(self): + for child in self.children: + fix_func = getattr(child, "fix_heights", None) + if fix_func: + fix_func() + status_names: typing.Dict[HintStatus, str] = { HintStatus.HINT_FOUND: "Found", From 57d8b69a6d376cfa1b3893b0aeca41db3a8c104e Mon Sep 17 00:00:00 2001 From: Justus Lind Date: Wed, 2 Apr 2025 02:08:09 +1000 Subject: [PATCH 14/53] Muse Dash: Update Song List to Muse Dash Legend. (#4775) * Add Muse Dash Legend songs. * Add a new SFX trap --- worlds/musedash/MuseDashCollection.py | 2 ++ worlds/musedash/MuseDashData.py | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/worlds/musedash/MuseDashCollection.py b/worlds/musedash/MuseDashCollection.py index 8bad02f2dc..96a506f2fa 100644 --- a/worlds/musedash/MuseDashCollection.py +++ b/worlds/musedash/MuseDashCollection.py @@ -52,11 +52,13 @@ class MuseDashCollections: "Nyaa SFX Trap": STARTING_CODE + 8, "Error SFX Trap": STARTING_CODE + 9, "Focus Line Trap": STARTING_CODE + 10, + "Beefcake SFX Trap": STARTING_CODE + 11, } sfx_trap_items: List[str] = [ "Nyaa SFX Trap", "Error SFX Trap", + "Beefcake SFX Trap", ] filler_items: Dict[str, int] = { diff --git a/worlds/musedash/MuseDashData.py b/worlds/musedash/MuseDashData.py index d8efadd136..71d69eecb5 100644 --- a/worlds/musedash/MuseDashData.py +++ b/worlds/musedash/MuseDashData.py @@ -627,4 +627,10 @@ SONG_DATA: Dict[str, SongData] = { "Sharp Bubbles": SongData(2900751, "83-3", "Cosmic Radio 2024", True, 7, 9, 11), "Replay": SongData(2900752, "83-4", "Cosmic Radio 2024", True, 5, 7, 9), "Cosmic Dusty Girl": SongData(2900753, "83-5", "Cosmic Radio 2024", True, 5, 7, 9), + "Meow Rock feat. Chun Ge, Yuan Shen": SongData(2900754, "84-0", "Muse Dash Legend", True, None, None, None), + "Even if you make an old radio song with AI": SongData(2900755, "84-1", "Muse Dash Legend", False, 3, 6, 8), + "Unusual Sketchbook": SongData(2900756, "84-2", "Muse Dash Legend", True, 6, 8, 11), + "TransientTears": SongData(2900757, "84-3", "Muse Dash Legend", True, 6, 8, 11), + "SHOOTING*STAR": SongData(2900758, "84-4", "Muse Dash Legend", False, 5, 7, 9), + "But the Blue Bird is Already Dead": SongData(2900759, "84-5", "Muse Dash Legend", False, 6, 8, 10), } From da4e6fc5326e30fce9c5e7da2ec51d66d879bf52 Mon Sep 17 00:00:00 2001 From: Carter Hesterman Date: Tue, 1 Apr 2025 10:09:59 -0600 Subject: [PATCH 15/53] Civ6: Sanitize player/item values before they go in the XML (#4755) --- worlds/civ_6/Container.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/worlds/civ_6/Container.py b/worlds/civ_6/Container.py index f920781bd1..0c5340d9c2 100644 --- a/worlds/civ_6/Container.py +++ b/worlds/civ_6/Container.py @@ -48,6 +48,10 @@ class CivVIContainer(APContainer, metaclass=AutoPatchRegister): opened_zipfile.writestr(filename, yml) super().write_contents(opened_zipfile) +def sanitize_value(value: str) -> str: + """Removes values that can cause issues in XML""" + return value.replace('"', "'").replace('&', 'and') + def get_cost(world: 'CivVIWorld', location: CivVILocationData) -> int: """ @@ -63,7 +67,7 @@ def get_formatted_player_name(world: 'CivVIWorld', player: int) -> str: Returns the name of the player in the world """ if player != world.player: - return f"{world.multiworld.player_name[player]}{apo}s" + return sanitize_value(f"{world.multiworld.player_name[player]}{apo}s") return "Your" @@ -106,7 +110,7 @@ def generate_new_items(world: 'CivVIWorld') -> str: {"".join([f'{tab} str: {"".join([f'{tab} Date: Tue, 1 Apr 2025 12:10:51 -0400 Subject: [PATCH 16/53] MLSS: Fix issue with door opening earlier than intended (#4737) --- worlds/mlss/data/basepatch.bsdiff | Bin 21812 -> 21828 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/worlds/mlss/data/basepatch.bsdiff b/worlds/mlss/data/basepatch.bsdiff index 18d6b56e59b746ecb6c7f3db8d297e2accc305c4..5715660de15f8a9f0a05e5e17e2d9e6ccc4542e4 100644 GIT binary patch literal 21828 zcmZsBWl$VZx9#8#gTr8hYX~wBJh;2N%i!+r?ykYzHE3{$;O-FIg9HM}%l%&6y7grL z*ypU>y-xS3uCD6UYm2Ih%ScK>*s?GI|A{8h{~rHknE#rI>O#5LMRe%Ybo44~$pIK` zfq(y>=>9(|duX4O4A+QJ9^kQht3%{}uaRB8`roMJwIgCEz(Yf`H*-!I1HZ)QyT#^F<|^2Rh&|1Z0M*wltrUwaC~esH9S^q>=-j^s(k3E?MR-H|Iix zCV2TO>fH}l4^;aFvkIz8=nt~CsBsL6vBZ4FeQ{xfQcjk2m=KAN1g)!s0xIm(*=deQE2PzYks9DutJ1OmVbFA5{(0N|GXT^9tHgHw``DFRiT`xJ5~7h*sF zK9nWGFiC>K0P7?GWWK}(;~yOW!1zb|SA!ENBrg$$TS5#1NEZW_mOrbBM4!nQq|zy1 zKpy{XqUam~BE|q%7s4b#tjlvu|BEM|12P|nLKkfy000!GMC2R-T@;3bh=E1LY|?wH zlqq?ak|>iqWu1c(@2Xjyx-Yr=UoQV(P%9*%lRu8;k3Vnikg_L=!f1;t|IBgmDJBo2e%b>wz6I1wdHAm}Rr5sPGM zp-IMB)$~TOr9Li`IRcY5h^q#lNpGPa3=1F^PP0T0PC}wk4n7~{c#Bz_MS*y7`{#(V zMof{pP@TibbrUa1T@dprkN#=c7c~g3Y`m&j;7*aGOIdX;4~NrRok;K@)Zx+@-h$9s4GdV+0BV0hoN%eiD=eO)yZZ2#dg zGn1lx`#`!g-lrwv;YGk|p_spPi|cm)s)JN?OxBW-7t-~z|(?c=)8 zzF;|{!1PQFxvC4%SD#*gZUI^QR4Y)d-YDsM58tXM+9#nFm-)8bY4Zn&{kN;D(Ga;a zZRYKozG3IqO|HhU71SQ(&G@lBYf>)4wb)o%>mT1@sUqQ^SU3zhS_KWrAqF(h7x<|~ zg;n{aUIQkFYriYE$cFbA~S+~KrsW}@cw zFN?KW8Jd`9h$syno}qaaSWeANO$8T<`=WzwKA$4kbaXQE(0QtoaIz07Ie-~W#TI9^ zrw4aJbv^tj#~J!%%_0Yoq~emse&^_$8eG!$ifeU97>J*^M20$Jeh(7m3GaWWF+AA& zcw#Quwa$tgP>;V+cp1q~T7w8Xm3lDdY^^!DAO!=4jZzbn;#-KK)0 z7Wy50?qn+S1Ntk=J3>87hAl|LGWT37BNOmoTt(blVu`J$P2=vRfhicAqBy9$EQYYF zyb&W_f^9!lZ~333J~aQh$+hBqK7aW$*^Oou_4D>??s!amPWbj#i51oQ0G2;7461F94;w@-zm8(_LjyxM9%}_})|J)Zj*HYvW#-Gqgwo z_&6T3nr3y?MB`;6@k8oRf@Y`7O>yiyXudp&itF{1kk!+QoO>b2NgoX&f63n$kZ;l^ zq~*anM>a!3j_}p~<2z@zFmCMPQOrN>K+hcb6=UKf$I~IB+nYe|wd9Jo3A?4Vag?wq z=<<*z3~|FczHw6ND+WeHZ)|j+jZ6%BUP%2yR$7QMcd(&q^ClKO*Ipd8wFUvxrhf4j zFil$xmQA6VjTF4b8zjnr30ISJ!On@ybWpH{&9X4pg1+jsgK@ z)|PS0hg!orz8i6==v<=3c0`ja-#6HCsFo-l0Ihs2lH8x%r!LVmv{fa7Wf(+?l!zFd z?eU{YhUqeJfp~hf-0emdQ>M@~BC1&MoJKKmht%@W4{HSM$qjI!f`>h#xeP1Z=gKSw z^)M1UTw`t(MuAK9P?97Qm~7NR`Mi|Sm~&>kKYn+u%bKXVDGdrU?}V$(oof8V=}$dpiOw|MIReWvNhL#!T?z5Vo+N`n*KEK76` zlY8qzPTxPNpqbe^VjBns#CjyEO=Vy6w0xyGdL2j;UMp_Vv1$n6oTGA=LKM*y{oeWR z-m-26NyFzt3eadXByB3cHu z)isAj>rF+TD7@aDOR;u577hSvL>yi9;}Q>aNYh7&$|yBAX1@JIPP=E+xi5x)Cj!;? zQxF!LBttfiMZla)32H}G*GA}Z&Cj!@Y-kx0qsDW1GtJgLJU-}nAvt^2&SGwl#a3K+ z$rJw8+Q-kuBzwKrh(QuwzBbjQIw71dD~4L^sYa`NYiDQY<5p_TZPwETOw7Q&=qaee}gg9yI$Bp6^~wp$z+ zU~BaS1P_EjNM`7cIV(78?3UYg^troHMj6KGih?LzHPd#ZrTxN)oLvlY%(IP`yr=XV zrB7sLa+Kq_F7Ho`bMU|ACC^c&Ipa4zRv)i%LTp3fQnNx-_DWz>0)gsWfNT^ZZc(EX zI!moQu>g!UVtTZN2U#v;cC{8Jjk!l5Uu%VpU9>NKKwaN?w^12hMNzziQix`mb^}Ns zgFz#Z?5AwbTztyS?K`wVVaaGNglYg;93ECaN?sV5ghbvxz8%h*jvb>tVu1t)7*+_s z5s{RQk~A3>4I34OES-iSJiG+YrchdmCxc8Q!_JO^5{2X7cb?cOkUpTIUu51IJ$|%U z<^Go^X46$e=&PZoGta#L0*<9Ijt&UIn6&qWh>{(3MJ6z5$?_g%OH4q-xct#z zUL}G2CE;Rwk%Qdx$+g!{tKfp*sxhY8pG$FFBtG_+kV<$ETE6bsgiJA@)P-&fq zy`~;_cVqL6C(b`E7AYb!#>&CZCDw^Ol}_vYTD2)0q?Xtt<@K4IC2UBa@xI~3XK7M& z$-@@ZELjsa&F?idB|TRNBpH$#%yuW#%)T5(cS#?MbS~1n(;%+kD2Pm86JZ0C*A5Fa zu7eq7j~rWBwYh!{@EMc;>gTV}-Lc3?6TL}qs)#ei&!i6UXD8N*9A}HCDJ+#i`~p38 z^o=bOk7B4~xqJjI9b)Q4?YP1!42itvN_(rVwM{2juw@1tQ5D zER{13Qy&HH5(S7rQ3-fyPFM`@PNAymEF^1Ep{P1Q*2l(?8kfmeHXx$B#D}{?rs@jM zUg_Um(^aH`Dym+U)aNVOq^jBkB@6zc=*twJFJ&N*4_yw{k_`sAIAn3jC)8R{_>nv( z$X*Qi2-&H`p^*;p$rYzhRiK+E&*35wss2=)NT;O!&UlbZO_!)nqI{pIK%eH#QBXps zq!y(pGpS7oMRx`2oyd7ARs7tdC=oOi5w&-4E--v{4WrL3;$(oMg}z{ zDq|b-`Q$S;5OAVA9%K#$VU*4RpdbLq95Dz3fB`2={O?T2KMDPB03Zrm7Xga-TA_!V zG#(ZA$^Nrgux_Gp4xLFYPzi zj(_EmO6ZNW95s%%qu;!r7E3SsSoK#Tr?0J%CXT*uIB%g`xTInXsh2az=wJ3jALy&iC##N4SZtK`e^Oj= zd)ign&RfXF0Aiau`BAmGN_H_HKlwbUFXyegc0$}Nw2G>RWq{%FH7VC!dY4acRdf;u zL`{79b=n=JtXChiSM+9$3vJB|?Ul~=*Rl%&bJz>-6)r#XH54vQ&3qKiOR)QtCtFJL z>Ok3Q8v*kkkuEz+f8l*o>S_&j6ejM-^0E18LfCTg$csyO<`J_Z=n*yjlCVoQsXQs> zx+2F&(t=*nirYh0L=ucuZ=v_aoczS>*Lo$Ahv>u%t|feqy=<^3jEQE(@9XHg$`$)K zhY61hG*1*et#Fa?uPe&I3s+ja!C(@#uW5xwwld_!my?xsp>XE$JdK2a>+?^$Xcfy{ z4~lYVxo?o=pFFD@T$ssJ11o;De^{;^KDxOVL{cIS6gT~B&|iVm%rmT3D&CY{5d1xa znL5nTn}X$RVIV+#^#$1Q(L*g2%kmcsuh+Cvwq}rK*d53BS@0R?T?mZ+HvkJ}@?U zmvXGE`SQ-U7@LoU6b~*ySM4)rmw8^Eb`BAFQsvfYkC)1X5JH9wY>q|+j%tj1Djf;L zC$Pmqoiv7YwD|cO^i=J_)5jGNDqEr}ENDv{I3$8o z3&1&sT{W!Cj6ya7SNbjU-X$bP@pKd>FJhxaTOC$9@1t1k;KH@S6zeR|u$8#J;fdu2FOY%-AY=0C>h$ z*#}qNW4DD+neQ?23uvw|RsL;)krzVd zOw_7?ARP%YT5J(l75veDIK3d+YS>(H{MMM@fFL9jJQY?TJRBh&p4~yn)(HHd1l&h_ zkqrKZK@yx<80(0DjO#l=0b*u2r4iVKfuluHrOGk zn$j@_Oc#M#{G*tO|40JZ5%Zx`L)>)@Hj3~LUWC+KMMH(>GT61`GJ9U=gj>3d0zyZ~*)3osY9x79HXV>c|D(Ns9WMD}C z5grG}ApprCgi@Lbzh78kl8Gn^SyM!FNxW|uSgIssgKbm**ibLdVt_jzgW*WVS@{!{ zGtDHTMTk?0kvFpih1(iI54aFV$7`+DVvg3f@NoZ(cL^FmY2EJI(TEgr8 z3Nm?is*m)>!i=*2nueRFFq1;eP=*~c(LBlMII&;_uMXpfp?ZA{ zROPcIU}5myzq_Q7rpTf7MIDb(B@+ia4~OA#t;Hr#u@0b{{{@>yXO+QounmjQAf3LP zG_0FVZU)ODn~lCR=A>8|o0^RQKQztZYy%o8O(cga(I=%gTY*Fv{G$?vRF2#$BKv zjxKX%`p8B1H5-{`f#3ssIiV-1*8cMnlRe^N)UKd~G-g1lnDk^@jj>&mXN0`PR=leA z_0vL8HD2iZbPtEO4PrO8OEN|^&l=(#kyJ)wQThu*y*uC6ZGVV9DSDEW_F`se8XOt{ z^=jK>Tadt5-Hs-Xz9h@$Kvz($rAU2uE^1UuDe1WBh9Y==I)OXP=EXNnTYjhgY?gr* za6;O@evkLfd!~}w_7{*@s^2nEVz9jcX*n%HGD0X;q@(?|)W!!(&90k|^EV70gnJ>$ zNtYk3^3vU$guBW2RQ9*84y~&Wz#UE*eS%ZMO#&;@D}?*XUU#C;&7>Ng3A}3deWJCq zwV0cf8kY1lGvB?8kF#rmR*_si-RZjmJo`|#R~e4|s?jor8Np&q(dr??_5h$eB#No} zQ7x;lOkaY-Y1lUtukqo%tKZO`niV!J;Mp3JRf+o2rtb&E$4sJrX-b~FRZ=1m@ncwS zm7rZWOBYQ}4OiQ+qlNj)LFRX{HSGg%RGcAue|^URqAd3sDGO0tWESp|A%uBrC{FCt zFa5Y=8Sh_`W!u-)810LV`5{+^G$&NTFXO^vib(!Rsz_E%1UeUS%c9=a7KEQaq@2jq zvPY~+zzA3XN{8;>XiXeu7k;iNbGjz~WQf1)8GUBUrfQu^8rX27)5Js%7B<;TXxC8} zV7A0GlpD2ev?M|6*q-jRm`HrRmlEJA{mie!?Td8xrd>)xBw=8Jw()G4RL|QO~`3xZqr1 zndyhbp3sj#@I(X*BIR#79c6QUS93}9QRLCquc?fHO<`sGd?`LzlrBZGs|}U2l;uhv z5a0!@iLw*c{r5^MKBL3gW;gT#EWW4NP@qFH5L+Bm*5Z=N5M11QOK+WE>~axp;9@J! z0T#-TSh+zEmjoD(a%x3h07aM;-^1E~HE2aBs0(byu$`hWAnHln6?9V~NuE1xb^A#Y z0Q|E7olGhW3IdKpT^3ND6;;HU!#ud%lsL@f7V{a7#N>ynmc!sA=6IeJ69kmKy{tB$diaYwO;On}81v;kF}GfuOhl{Aex%jFj|l|DuD;&yOKuOcCEq~|I+}@6k+IxgJM;qXj}x}n zh2$prYU?)LT~?o){C5Z2E|5eXLV{BxsuQ}r6U-uimbIW zM%X}f-D|lsNa-l#u!QYfjb;<^H(k{0WkVOPIZklk^(Idj-SCMe-$9wtCh%0CJHB(1ioMUd<8R$g-mgL&CYW5UzbTp=2oqpH;7qER zVLVhWu)Z-F);cRwoRf}{ec21+z%gdjwI5LwElRwr)NHiX7tn>t(@|dfJIxBC=Vz0~ zis6v(%!2kZzKjUwY|L`|as_^goU^Gk;nrpG?9|7@g{Yvb!zk;%WojTEClLe%n?I}Q z2lXJgCuVv-+9#FfN{S9nt95#GWx18iVn;Hja2(Tnd^D|R>u=gnj``;FE#=aWFy{ab ziwWz|7o?d8832VB)TX?2@(b8gls1p@B~d4cp|Jd=yB7ntey@eB{nGE1G3r<6UEp`h zF6#ztq!(I-9gKkE1KCJ*hX%(DdN!J3^^he~DVa(isFFM|D?|2hdV+y@VeO^VODCK# zfr&Rh(jbb3E~XepesiaKMk+Mw2qv1jm@=jljsfw7D_<&_gV-e85uF-EP357HVm) zda#`+d_nE!018aTF;;9tqrC`q;XmP(Jhrs$3xFIYYY+Z89Qfkz$)V?)UfZ zxYK~uHh#?x$Fwx^p(DjNhad@mAz`unB$@aNgMuKpp5Vb8X7MK5Fah-O%P~cM=fk`; z2s(Fy3hDr|5HZrkeSdn&Nx?-;3S^rli$}W;V0Ub$fTk9l3SS%FhkA?#P-6TB@?KH*WZCscq0nz1Bm7e4|#zY2U zL=gZig~7MeLQZUBSA^h1nfdKGg8J>Wt37+8&tA8gpaaQ*n|W#7_E4uA^8JkG3|;jT zdt){U6ui}41~saRtZ>NnnWVIF+|C7Np?H17Nh{t{wSMhU9#W9PV6l#hRjfm!`*YEn z1LgjmS9l{|>c$FLf(@mMB+bCAva%Jo?^k}566v(jz4TV;AA5;z@&WXS;m+1Q)&8bG z9F@tHIo((+Y7omcz|7yo@loZ&;VJup2R+BLK+^PJSMy9!JBcq{rGFGIN1-NMiAbPz zvB}}&iE{_^m&_A_`$%@OvhijZvY!r{V6HQzr)oK9Ns z=?O|O^~pOHzzy*qtJ<6x;Uq?u7GNZvW)|qmVZn*M7@|{&VKy#Vw4gU=HVBhO&EcT$ z%8KtTCXomE%95U_{MPW=8#YZo{wFp+mFrEZ?J>Mc74clCpfBeSz zg+Gv6On(+4*24M)E}+BpCASr4zKhZ*q_ilSrk>csmNZs(X66&tPEav zVF0JYlqNv(WIww$ROfKeXN9Kz(Z0PoapSaN3!#<+T99*s#|I2UhttJ|+2LUAtb}!# z&N*G8PR(h{JiA(UDgIa=s6J4Q$V&U$-{P;>uH$c8G1eWs-n>;|E(l(G-To?wp;{N| z#a&|3;c~;k6c@*l8V)e6rfld_YRpUy;`hq{-MzE_E)#jH;3&duCj@bkFbHXx#iZaN zBas$y#fVC6mZYk0W0#U{f0q9Cvz#a?9kyd38ix~y;NdGs&zm^8o`@IR;Ei=8iu zip{;BSrJJ(7PPeZSy#D+gHp+s`UYJoHCCBwbM&l_w(-_rWT+htt)*V#PJ_XqRHK|N zu!#8yH{5~^E9jRrwK)m{tx2-JRv{s4O0jeJ-J~-a<2FvtldGF)U~-lb`0G2zRuagQ zwAGkPYoJD^ecxvJ=wl7$vf8cmnoEuaB$!FR3vOL+ATYi?`MRunD<37Pl#g-Bx~hpY zZjzJqS*(5_@B4FkeYTRp4-WD{7^*m`C=zN=77eFkAH+W6V;s@ZYq1mA6xQVBP3oI= z7d+O-4y48Aq*;PpKnK&)a40Oql-&}YQ|0o3RFlwu*5~8xg$m0zN+0u43bnwYb@g*s zes%SJBTtt!Alds5y#xZ!gjqcix*p%JY!FEMlHbMaZ}LAL7-~YUrb&J;hq0dMzFwQX zAxZ%+5O2c+lU`qz(PW=9GGF@*Ro6zYKKRS%vrTFJa^AP*?`l1-D*hu3S%z)N0}T7& zO@D^Jc>m-+Rp42vZ)4OLJZjsZI|T8`HEs-i3QcN$Iq!*Ew%0tc*-a)uba+ID)y4{d!7dTc=buuf{BKm69>-x4X^6 z;~lx}m(xL#u>--c@(!D5T11x_C=SF=IiAh?au7jEnpWK+GnuHU*rqsqF}W5PPbQ_M zw)OJciXEO8NI>=2h8aYx$U->gQ=0H(v?!>~klaR6-7t$SF&3WDe`SK?-D0Up8ig6| zL|c$&e(APD*W^?Wqp_#bYG%)G{xNwaPN&PEgtKbo2|4E=E?D|J8MByRIYmNAt? z3z{W5Hh;_1XyeYIb#?bGgw^5btqI+D-jmlRtr^|2YwI*WH^XlKY@1Enw0RAOKq}o< zlQi)|Mp~Pi&pwyW{+UwwO;Jp!B+1j>o~3H5=tG6IynhDMfcN%-d%=s__4G~kmo!Qb z+Lsp2j-ds|`{}@IT}wl7xnUlwhYu>Q!j?lBE!x1iKo9rN!?HE)-#e}Nt)z;wuh9|; zChjekL!Xm(ejNv@y%T@jwjk%#u+V&L^2qihP+kbtmn0+3lQX)&^W&^`)A#r#$m@-6 z?M|^=m&k>Kt14`IK%4QK#H6A}VPxVHQ5b^P%XQ1MqSEN`$NeO7{nS1SYa#<`vkChh zA{+3Uh%|Xg)USbX-q#g(EuDydizpHzNtm}2$#r`KSP5Tl!ww~ZAABU^arJkzgfJCx z3DeHKEBdTcGov;>ou#9+0oO=pUhF#hahJUucM&Z4xvh`=iOM_opMHd#THLqW$f@-d z4k0yu+4JH4B~d^{!O4nI`YBxu;KCl1PM_%7?nBm`I;WJbU*(6-F(1V|!|dj^Wu#`$ zK}IH@b}_U~{o%`I>w5U4#q=Yzkpn`-!5Bwos`tC{l$N}P|F9Veq22oVcS0Jox2Rsy z^BeL9UOny9)e8aE3cU)lcud6Qg!8|Ir+UEI-X{e<11AOl zu>0!HKj1z_TKOOF4;xhaa^YiK8oCuWwIl3jdgao5!1H%v0V~0V1qq)^$W(k5X4$2r zE7EV+D@|ViF2ojFu7bxuOR#MdWF4ypaPE4#r2{Dn{4vCBFdq2rUhUkS=*q~|6u)Q3 z<-roRqt*B63M)u=Q?@Jh&MSH_7V{kg-YaerX_@KoJ^Q|j?n~TJz-D%bR!<-panWkzk6uvcV2r%#!%J&^T0k;RPBeiHC_v2a6+V3x$?(qi5Yd+6gg33Xhpr5 z-7_c27+gnnDUud{$cuoA4OWu7gzqTmV*D}4{(M!A?e?^-MlxRbC~hS%Hl|+QNVT&E zK4?9y{dx6$p>Z`vq8E!dsBal=0a`naP#ys2XFzAzTiz1>@+*FHSbv-AveoUik6`4f zrJ2O9)*fxw@+Txg^ieZ}c8j-_Xp`sY7OQ58{g7&s;enEtWFmW3jW*DQtRd=Qzg!p^x2&4N0_HS62rT}YrDl2m!Bef8(T!BmT z!kXaL9*@n&JL?Zs{3b=w-_=V6G$fF_Q*Ok7s4g%*7uM0VG1!Ln^fIs$2M`gj{w?qZ*7%D5HY&_2eq>>;V_|>@mhOOprPBZB1 zwBB(?kA0fc6euAxZ!nl>ZFsF%GLHi8Soa4|&nzo?@m+C|&oMG9mbc<3eOZWE3lwh# z6L|Zg!nHjT(*I*vrHfD{0r2Jn|8I}#{qOsKN;m&~V2@Uayf25?VcWgn=YSJ=5L{*r zk+e#Mm_La~OVciY_s-Y#XW#4V74OHM>&;#7U5~{ZjD(wX!{?S$fjo6lTFJguzvtd7 z3V$unUC)N}=k1Z}dB2xuN6)(LMdz}X@3~<-T#%rD9gXZ9fI0cBtZYcXB<_M~u6#w( zq_;xhqRep={SWz^f~rF?Nf4;2U2W+>Nj*S9z^0(Y#z!i~QT-meG)+CP1W8o&DM%#} z-Y9BgroS5bM-5h@=M5oC&`EGPz^Xd{ZpQpSK&QYrjL{@3@K1OPn#TV!!T6sWc+ zS?%>uQ3mk&QVoK>Gp0%vqi4gMTem^xHiG_jhC(>ZVak{O`BML3{&zyb!G__0f}mM* zbUAoCGzB)+N=s|0BZ5+eHa-=l?~KQ(D&^*rb}A*1`15%7s-h6?R0Zy&`9dsl08AlZ z?qA>MH~^-oqUXfBo5jXqb3MJb=2g9nst6qpt(-=7KS>n63L97|OYF(Hlkd4=GmyQM zb&8QZ#!485QDz zE8!6}^9qPBBo8*HzDWf0)!{S6l{ymWvzBUel;dNHIKZQ$7!}977WEhMY{PZk|rPxWlXN9w|8ztad(U zb*35y%|fPs&;fF^fHS6I*9opn95p8*GEjB?qqqWbz9o~#?`k>FfqZoo(TgXSR_3mb zCvUTJS6cgEp8dY{Ui5<&QEb@ez}KDxrZcq=hpP(>X4rO&Y)8{Whwlk(=tT3mkdY-j zQE<(Oyc!yrDQ*Z$ju$2E)V&D|NeXoU$bH#LAwScgCbP?3I!9`XCl5LL?orH8V#tV& zhP1Co>&MZP>X(yl@^A*it{1XQJJQA!)#67%c8@y+~5HWtk?7ydeq*0&8Y(Pf47Uf)Y(9!~r0O%ko&zmk@5-QT{^ zWZk2RM-WAiEMHn=-|stNfQ2(UI5DPN;iXj-Kj}M;ENXV{d&qI}9b1JY*?bbm_$&HxV8t?KX=6INBp1)=SX^Gaj2n~*M#@qH^iCcVuD~kxHVzwN zdGiHBK+G7EC@h*3i~`;WuL>Xv8Ccdv510lkP^r@F;~&7|i^f^DP{N+1a{cC^877LZ z@u35lnY(C;hFex9uP}9wJjAEq=ljS|Mz0yjUV~=DbFQ;7wTQ)Knj%7F@Hc9d$U^k) zu-rFV=LV*+s)G&{`8SZ+`R&xKnbEy(=6@_m;jEw-H>W(8!nD9Y>uh89Qv>1k)!t~J z0{F5_$!RhgW^d4{svP%ER+j!rcIE;!6hNRG7O>!>%~|OJl}gZfgOlA?Suh#dMl3vb zx?GrfRprgE8u*ZEBA+|+weoC|MfIDjSUoIL)&X5bFW(-vl#6rKUYb^IGe#O%GJ>MJ zE4^=O8+v9-P{-X7X5EiH0}k>Eqb>uH@Td1_N%Loi(nchy)l=zf8`aE;Xv&@+@lnOV zSqpA6Neqf(GR2t4j}9X$nao?-t;wU6B2Lk=yo_o4=!9ivI_d~j7UiX=NHX~YC{+z~ zm}IsNd~n3aY4kMU2IW_LYla?EeK$W$o+T|lrDTOW?Ey_pml{LSi%gL1Y%c>It_zoW zC_$xW`)|T&^_QN~Y$3yp*q+gUD-?O%FEWb4$2_hn1>B>R;75z%n zIP7Rf=8S{>SfNX%N{equD+J+$mKgl?`a>(=IoX2Hz`;CrZZnjOcOt7Pv3HxLQSS6S zb^`D4i8ps*{)P+1B?iJx878W#YOH~^C5Pawr->*xlKC-JHj(EAhFF)^n>YiGD;AEC z;USI^vi($4JbkU8z+OIPG4O!}-sR+HqE_cxS6z6Nx#j5$PVOTL9I`z*X(p@RZ9qu@ z6QWd_idI5+x5dJUna9F+YeYt!hVSd;A%XnU-+$^c-MC^`+FRHdhI+vhJ`Tz0cq@FAk#^wxzRO@qPSFP<*a=a9Ow;k;R5OgJWrTC$DekEMoUOh*#x!19- z|2gi_P(^1{Z_BLZPLyX}xoV96SvD3gD!N^kiH^!Vk4gqDA-Hw!D~6%fz%Mq}?hqj1 z*8;rXhV-a$OHGJ`cn9-J2Xj~m5Q9yYjY~GC3xyrb_+nZt`&ff~_9?n5Cwa2)6R!TH z1%mSG3|8YMn^k(r(sFV9?H_63eceiN_t$ld6aVc66|xfP`|TsLwDT<@y8cu10i zY(Qh|n=@=YXm^o>9M2MU(P07})7sSky3nF|<8iXd0kiCuyECn38CusD$XX=<%g%sMMJZ=-SeAhEnO^Y1ZP2SZ7w&O_vsL z%hD9Kpmu((QVz-qo96hyp}YP_T=2tcXYu%vpg^L+~$;PzgQM=T#-y~n?VgTR<)dXKyv?q(l;7)6&%t z?32i6?v@`+m#B`+JW3;V?#d7zlmYq5~kjCCE6X87xZo7z%R zpX?7lj8AuOl6P##faS~h<2e0Lkoq!>#}PBBq7Nc%=Nr}|zo zt=pOY;@GPX_r;YkA4U^*cU;t$u@J$TEB?2WA<77J3v!?^OI}~_Gb&Oe^&~m=?2i^B zedfafI6X>hs|&K&-t402PgdwSQ(-W*{Svw~>sQ+~k(d+3>7+ zy8U$(<$bo5OecQS%$&P}O?CwgpU6i$II%r4zQB*`bFZ=XJIzN5#&@i&Nr9*NYMVQA z2)o{v65OJQ-su7~Ee#6ju0L!MaElRSI=WQ4H5E1_C7)2yu)^Ag*wa0TWfsRl!V4#e zw*u4Ew(yAzSzJkLlq{GhDubi4nk<*B?s3+tDI38P)!6b=Y>`DsCPzb5fwpY8*eGQ^ zBJ=`c$mjdW+~ShhM#plwA(%~jz0A*Tthw-$YBZtg+vyP>8Yk4$BbCcd{Ow=WWAnhh za?uSCz4ja7(G4+2wu>+YEd*Izmcz^+}0lMnes8HWXaj}4VozG)@u>A*o;!-jje>V*3 zY57ER%+=#amWqBZk7GQT4Y&m4c4YYt(1z68!&5^p<^;#dF%O<$wIhbm{{SuHc~EHZ zNk_B4W9McMnhOOGWhh3}O>YfYdf3g(WNycD=?Z=d?rRH?D_PR=mnEzp*xqFFy^5cDX> z4d99$6}&H=&wnyuY{fA1mlosvY2B*pYJr_Zt}G-jH6c56S@17^zXr(_HN|N;#9gwN zmjN-G4)@o|)Y)>V`|)m`hKe_r0IoncrD7fg^)MXn&gAFTTceX+k)7Zy*C$CT>kYmH z22fbTB2?kb8`%VBazJwDItP`r-Z zM!L2p)}Vw3jW)e+VH}zLYm^D;?^s}Ic&g=qhTQd3hHc`|jKJZyLO&!J)$O$HZtGEK zG3hr6N78Y(OrnW`Lrkf{?EjJi5{y{>#Y>3WIz@&n2- z=4%(qfuGWnN(g|+FdN%+u_Y57PWU7bx0*YdcaOmRoXlr(06b}Z=WXF@8e z4<4D`&4z@_=_j0-S+_0HC3*(mkm_75SiuI)VCwFQI)S8uneu84Y6};49>K(U0}1~y zF;H((fM{Iwj~{>SLuE121K7UhAXQzS)*dN^JiU8+#AyP_3H!>11viavL#4s2(u@Sadw#jR)JvV=va$oX$(M%;Kq!~J51BLSWR z{VtIoJeo6qx{>W1(fO@xCLK;~i;!a@|C0*}Q-w%eQBNfwXmpy+lo{5EGg>}-#O7Hd zF@G@AP z#2kqU?=_l;na?pk7u^0Tk|S%p7>W0bT{pPR*nN_i&rm#>RtF(lanWr`UDWKS{hzaa zVPT$b%UZnX7Luc#C;{-`5z%)VmDX@P#H?8p;+mFX2h0PBFH%Q zlZpLdoZp1+Ag0fyn*?DY0pS&QSvW$Byoi;u>-q$zr(4zG%%` zJ=qZo&!v5<6(S2_pn-r^cvZ9miAi0bJusLy2Qw6S&4A2aex#5Z(zo_Od-KHhoRbHB z;#_TjZ3`WFrsM09s7(f`htsq}kC*&Q#25u8;Lu{|eW&L#Kay;EaXTz#qzwhBK7X|6 zMD|L|(9y7kYZSZC?etC2f-h$}5)WPR%cn0DC@W*`|9rk#uR#TiR8!45Pf+=`>m+>` z1@lF)|L9IM{XFkL1$LBt{7(Gy-LJe8tqZ<24}mSwso@lq66I*9U9xPpxRdL5GMvu@ z^v*X;1Lh*YbZu=~@o9&85+Y1C(mLB3{>821^XzpNzi`|iUNZ;~B`aNLKSvlVL^iB9 z3x}7T%}%ZE&1XVd6pHP4n7!}S+Zu)L#*^d>lfYqDv*e&CXasvz(tE(oM{)?oM)7RB z>+>%*j-qPgCgGA0Fu|sR^fmZn%WnQU5l0+Mzu4(zdz z8Mlm4-WZI24?sLhW+zY(kaf|}YJUG1IbhmTX0KR|R|XHWk(^74Lll?sQ?rEYm(C`o zz|1Qh`aIQif6k)l$L!DR^;25D*VOJL6)FxeDpF>QGX4_W#U@8n(DL=-kUF|{K-+Frp>ClO@Cax;J;`;PqY<-%F)SYGPo0hmT$N0vvOvd%?`EX>zes$7nq8NMvG>NPFT2-jS{` zjTN+rbb8!)g#$tM_i2a1yew@6{SxRH=L8ue6Y!L{8>UtgWzqt0n}|`HBN@VMQ61%& zX=7yV(S=I36!bWy5x19c#p(PT+81*m;rLd+?{iI;O)Vj%$ep@1ZJy2`%yUg-AE4d* zUuwJIIcM_;D$HNQ$@u^zYAnBkDb@2-I-nnHfc)eMKak7WIxpm1qX%RCTTn(?tv1%z zMy?8^3d0h4kdo)3IkB1`yM{XcDv7vob6E4Fj$Q>Ua;vL=Sux-W#q?Ym4yu6bolx~E zVgZ@tT73Nbw4bikGt66==zse6`YU?+e_m!jo%(K@B;}!a8`g6hxl(b1q;Q|2#jC4i zD`wj!{{h;TZ#~`r|8qZ#2lFqsKTvU`(fLu+?NIpn_`ues|A#mtBM?Cm?8YEK0L2bk zWF&$hA_5QxF&G*Q)?js=oAvqCn?qoEL_ml+?!fhk)^uxoTofdmAY2RuGXbgqA{oy* z&b}ZsLAM*VYtu%3DTcD=eV(yRiU24G%qQZA?#veM1_o|whDF<_XAcC|awCt!2dsy* zc9P7hr4R|x&y*u{62k+}ENcs_ZpP3u*uU3wEB&`2fd)rp%iQ#MUVI*<<_EFrAZ*GJ z8vzq?TTdZLzH*WPv=|)T$OG=^_E{mkNEjO+z3)7E%k_QEU(eE1TEA{j^`Gl2t5W>c z6n;4viIWn8@KRLt{a(K}oy4y92@lIGO+QuppQj~a7}=P+OK);b{)N|G z`$9?mS4iC8`{<^W)1*`KSU>i{nLkRhb&s)N_N2o`ACUfFf=)7My*1&@Ra9=4fX&Y> z>fQcVf9lTc!>09fv$Lq}%Lp-itgOzSQ|GaliAx1{F#h*@6R7jQlpkG+{*k_(i{zVI z(p@SG9z92Oo5IX>i9AnC_V1am1bP(xUGHy!OTFx^dbh3Q>6Pb8{hjt_0@VLT?Ao$9 z9cvvr@J`*Q_N?SkqPH<6ZoF3ejhYdg+H?I0a63*%Qy-DhFPFTy#S5g3_cw(xd%FB= zO^0H7<~9e3rS`EeuskDL;w4r2fsxuJ0Fd7pMZTiVcnrn3NPdc$N6s zmdc9z&87CExuVs5H0bN#PKUUKf6`5DJXD@%ab27A9A})t+S16 zJB)~^Hp8@>qm2?DL3vDlKIVh__sq3xhpwA#S#b3=<_z5JTkZywNAGX-R`@Tq-9os_ z=+?TP!Zw$D!;RAMxO(iWP#(}|B;IpTFre!3E9GTnWo2}eM?~zGu}>$bk12;PG?cNu z=v=y~ZD+Hy@_jiQ<*s34;Ola*JIyip;>p{-aW!s-?Ag`c(CFqORef|DTfPPCXE56G z{f6vg!l{e0y|10Ie~~XEBO-#OrZj`w~` zJzSz#XLwn+w3Q@BsEvH{TSc@+xK;Wiz^gHVHWFJ3NT%13}jU`k&Kvwj+p{AFwT~b@2|9f0o^}QnS}Y#JyS8@Qo7$;Z9N!02Hwir!pjD-# zsD3)F{Hi!Qm^b#;Hw~?!H?kBf*G3LFPQzhDZVKW&mY064hYRP8RdsgUKb5Z#~is~1IAL{Ji^}Z!4%3rRA!s_b! zKezW#*{hw1Tk02DZQxK-MTKmvBD^fWkKhOP zKBD=~$EVW!Ep!eIKTQSH2pfJ5q3h?P(={&i(Q_}g8uFqfk+-)_dZyT(wI_%M_%qCY zz(_Vd=1@k`#l0~INg`LI(FO!9@O%M_jO=vtvo)WEt7{#q&TUY0QtaK^SSr4+{fFb) z6^h^;b(Uqaz44Qtk6w@iJX9Yby5h`%$mb@2=o7>RLmG$G7(1!ue$Rq;cSHq&7K#KA zU%P!+QM0SOrY2%ejqbFd8m$k&a^;R`L_pz~$(xC9^t@|7R)4CS@h<$Gsn~mk`(Ebe z?^BdqH$67aQl0D0i&?DXzG4JoBNA=;CmcGDDWtS*3^;`&Qw`2IrVeKH!F$7IrqGOl zJ5YwfSY;<0)yO>oF%?sKrYaJG5Z>vX;I`r{yp0PT08bzvyg5@xuBhcz!Y<-Da~A`~ zjjpD4*78V(3xvZCFv;>gwr}EbOc?KQ7X};9IE#&ivOV5FTf0Z|AdI^<`ferb17HE& zZ6GurL}I^$6}M?Lk_9n~STQ=XNujVz<;Z$vud%u_o}uGe! z9!Eni)N59cXt)&VSNl_gS>JLFk*FY67W#>SF*XF16-Bo@Ge2!JtO1fT#HAeB}1uOi*A zvzbkxqfRYQ;B8fDJDlO(YL=|Qg01X(%wMhH185Nl5G5rf>E>ekJc3q2sv%OZ5gACk z-ngbG7crcG^JBE6vFuO;g@}bRFhD$wro$cz`NWi(e}!+Rw|;Nvfz*{|dCa!Prlo0^ zinfz(OHtV7JW^?1D{Yz^iHBUGY@3G{;X?{D2-m(D-L+jh#e)|APM(&=>nqWJZzhPu z87^&@BoxUP86qUYVkz=2Jkt&qTca8Y0~*?SmUOj==)n;N1dOofn>@Q_Ikdb4xEbGTU+vhU_kH=XzDWyl6hg|`N_)*Y@r z5q+`^Cdc2xv;>+p69$>^oyRiUj~@vVM9oRb++>SyJMd8OwTw+tU=@*My_^Munm3p1 zaCRek0_W~_*g^{6i01_K0nX(Pd(~>SM@>`!aA0mF$I#-}o%47Yw{QPX=Y3c?8SI#i zK8^2>XUOZm_A+CJ7mvkl)!lmZYdjKsj_Y-bnVFH4@DIO%)no!Gx61Bd==oo-$orF_ z==xVWW>0f}yK9cX>a^H-O?O3<2Qi47he(3?(M9s|^Xfs6cpUcyj$#nY{j>o(^A~$X zy$1KX_sGt2-RRNO@3{o^L5d-yhDH4Z}5*UCDDq3cN?tM_#sUBHhULnf6c@_(1M8{GP+UvXs`*V}$f9vsw$`fx4hv z{1wnE?yM_-h`l`GTxt_d!r2WBHMniY!5kw6A`^1k~pZS1c|S_$dLI~sy~$Pc4?w5wDwLMFWX#Y1?aI4gr_ z2Z84G;Hp6O#9#;b@B1IMf~k-C<-V&eQ1ic^++)Lqu6NU~IliuU*6p*jg)I%mZ+W=l zdQ!6GIt{>m-haQ?^J>m=7=EPBvC{3Id>@bKapu*9 z1_8vmWkm%9u&xEIq%*b>Ov27Bi?Z_tVlL{gXL$SlHa}nY zeUBF946DXWqhAE%ZbV07RCv4`xf@?I->D!{VRhQ*;rMhkaBjKY5Qy~PO|Pf0>eGtO zV`3gWIrHbwpFVq~%v|c=b=p6;1dIW9&$*^le^-rR=y>xZfud~|{BUm?u=I)=6GLw@ zC?Bv$7}QyN?ZqzUvF!M`9ZAr>foz%FNpSeI16AHP5su@E`Z0>YzL!wN3lA;UbSZKr z^PbV&^zU*@q51ybN$<}i=zx;5{ywi60fo#WfanBjyndmK5Y#M~&u^uRtcqL0vgQ_s z!ycqq*U`ojn`W`vb~49`mPb|8KCrOPcV3sWs>P5$3^zhiuZqQAH=s| zXmB3*{K$`K`3p^TI?hp5j5zpw*m?YVQEK^dFEOF-7i0neh@vZqWSrYs5+jCjMNy0t zckAT9fsJ9rsB6R**u>Vy#o5DLTM@nyFv!cJv!J<&6#P-=wZ@#N}Q>Z%Kp?WJYvSHgkmFt(_juN>@8BfF&w7@6>ONM1QJ_LBFOWrr^x zdq36J!27vVXmi{zcjW4LKQ6b6Ke56$qE;)NU2ds%aMZ9WzQ6=z?w;8;?I{X=FD6Q3BnHBNF4eY7`&{ zf&k%E27h;rg*NTWB!%0++hL0XfUK>t#r1=x^T&EJPmsQ}TIo&iLj@m|W|ZeQSHAi~y=*v%mZpy3)@i~hMw zFHWv->9#j`nAYNo*}hhG_C^<-l@hPNwCP?;=XZRWo^M|7o+|4>sp}K_aQI`?qggYf z5+q2GEZMPQ#fug!Sg~G8)vrfn2tHks{ii^sDN0j{Qk11BR{Ye8^b2ENG}N7J?Wk;@ zD8m$)WtLK5h8SUn7+Mpx#L*f~2&j4PQxqDI!b&M2i787yT^Uq$uN(~>b-ESZrBD~- zIlSZBGF({@MArK47qEDKC!wXMr!He7K$m|SUZ;O9T)A@P`7GdqJ46tKN6kZbbz?~} zX_F>Qn9ZE6-Q`rg`nb-(b_v4-{a=w1!&gGbt+~s)*T}YMyMb;8z=SN6$G-e_Sc<{{b+@KlGRs%Ii(0is%`X1}T?*5+)Nu%V7}v1NtBm=Tuql<~3UTG~+ceX4`t{Snbsa zCppaJRdV+F-6i>~W{b01chpEyOHI5Q4(M=y<^_qi*q(ewr+K-1{@bjP+eTD)yg4_t z+^hJJc_E?34ujY6j&+6-d=3{TJ=mR%m!a!*C)v4A#Lk-TS8ug)h1t6G>xWR%fEjmf zJ6Ii)@gK218>u+2_qJxw5)Nwi6rsle9aCYKFzZD!GBgDOO zDr?$l>g0Uh%AT{ETqEFA&Qb`^%AUw$?NrP9biQ5Zyv8{We+Prn{fy^>g+^!T%$2l9 zGw4eBPRy_7%}UQjn?nNo#PJ5Y1jy%zK#1gnEqfu`XWyjm1~fjO2fb1%asQK-WKQR^ zmq-UreJu_)#CB0=w*Y^e0kCoy&=_&yO0!0?JO|uZadjUv)yzZ&R5H-M5(pbfm)@!i z)jvP6pTz57F7Ls$UPq5@u=CzrK)fDSEw6goSa#Ok`UiRw2Em!4yhV+?S@FVFZe9L| Y^oy@Xdp+N4@<04t$rRy2LRAEcKz<+^f&c&j literal 21812 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 SIR9V&i@744C`e^&QgDD`h6H#3 From 5a6b02dbd3c387cebefa1fa9f152f0051261d83f Mon Sep 17 00:00:00 2001 From: Bryce Wilson Date: Tue, 1 Apr 2025 09:12:43 -0700 Subject: [PATCH 17/53] Pokemon Emerald: Fix pre-fill problems (#4686) Co-authored-by: Mysteryem --- worlds/pokemon_emerald/CHANGELOG.md | 3 + worlds/pokemon_emerald/__init__.py | 164 +++++++++++++++------------- 2 files changed, 91 insertions(+), 76 deletions(-) diff --git a/worlds/pokemon_emerald/CHANGELOG.md b/worlds/pokemon_emerald/CHANGELOG.md index 4721c58d5e..2a0130b128 100644 --- a/worlds/pokemon_emerald/CHANGELOG.md +++ b/worlds/pokemon_emerald/CHANGELOG.md @@ -14,6 +14,9 @@ _not_ used for logical access (the seed will never require you to catch somethin - Now excludes the location "Navel Rock Top - Hidden Item Sacred Ash" if your goal is Champion and you didn't randomize event tickets. +- Fixed handling of shuffle option for badges/HMs in the case that the player sets those items to nonlocal or uses +plando to put an item in one of those locations, or in the case that fill gets itself stuck on these items and has to +retry. # 2.3.0 diff --git a/worlds/pokemon_emerald/__init__.py b/worlds/pokemon_emerald/__init__.py index 9996bfc6b7..c1875710ef 100644 --- a/worlds/pokemon_emerald/__init__.py +++ b/worlds/pokemon_emerald/__init__.py @@ -8,7 +8,7 @@ import os import pkgutil from typing import Any, Set, List, Dict, Optional, Tuple, ClassVar, TextIO, Union -from BaseClasses import ItemClassification, MultiWorld, Tutorial, LocationProgressType +from BaseClasses import CollectionState, ItemClassification, MultiWorld, Tutorial, LocationProgressType from Fill import FillError, fill_restrictive from Options import OptionError, Toggle import settings @@ -100,6 +100,7 @@ class PokemonEmeraldWorld(World): required_client_version = (0, 4, 6) + item_pool: List[PokemonEmeraldItem] badge_shuffle_info: Optional[List[Tuple[PokemonEmeraldLocation, PokemonEmeraldItem]]] hm_shuffle_info: Optional[List[Tuple[PokemonEmeraldLocation, PokemonEmeraldItem]]] free_fly_location_id: int @@ -185,7 +186,7 @@ class PokemonEmeraldWorld(World): # In race mode we don't patch any item location information into the ROM if self.multiworld.is_race and not self.options.remote_items: - logging.warning("Pokemon Emerald: Forcing Player %s (%s) to use remote items due to race mode.", + logging.warning("Pokemon Emerald: Forcing player %s (%s) to use remote items due to race mode.", self.player, self.player_name) self.options.remote_items.value = Toggle.option_true @@ -197,7 +198,7 @@ class PokemonEmeraldWorld(World): # Prevent setting the number of required legendaries higher than the number of enabled legendaries if self.options.legendary_hunt_count.value > len(self.options.allowed_legendary_hunt_encounters.value): - logging.warning("Pokemon Emerald: Legendary hunt count for Player %s (%s) higher than number of allowed " + logging.warning("Pokemon Emerald: Legendary hunt count for player %s (%s) higher than number of allowed " "legendary encounters. Reducing to number of allowed encounters.", self.player, self.player_name) self.options.legendary_hunt_count.value = len(self.options.allowed_legendary_hunt_encounters.value) @@ -234,10 +235,17 @@ class PokemonEmeraldWorld(World): max_norman_count = 4 if self.options.norman_count.value > max_norman_count: - logging.warning("Pokemon Emerald: Norman requirements for Player %s (%s) are unsafe in combination with " + logging.warning("Pokemon Emerald: Norman requirements for player %s (%s) are unsafe in combination with " "other settings. Reducing to 4.", self.player, self.player_name) self.options.norman_count.value = max_norman_count + # Shuffled badges/hms will always be placed locally, so add them to local_items + if self.options.badges == RandomizeBadges.option_shuffle: + self.options.local_items.value.update(self.item_name_groups["Badge"]) + + if self.options.hms == RandomizeHms.option_shuffle: + self.options.local_items.value.update(self.item_name_groups["HM"]) + def create_regions(self) -> None: from .regions import create_regions all_regions = create_regions(self) @@ -377,12 +385,11 @@ class PokemonEmeraldWorld(World): item_locations = [location for location in item_locations if emerald_data.locations[location.key].category not in filter_categories] default_itempool = [self.create_item_by_code(location.default_item_code) for location in item_locations] - # Take the itempool as-is if self.options.item_pool_type == ItemPoolType.option_shuffled: - self.multiworld.itempool += default_itempool - - # Recreate the itempool from random items + # Take the itempool as-is + self.item_pool = default_itempool elif self.options.item_pool_type in (ItemPoolType.option_diverse, ItemPoolType.option_diverse_balanced): + # Recreate the itempool from random items item_categories = ["Ball", "Healing", "Rare Candy", "Vitamin", "Evolution Stone", "Money", "TM", "Held", "Misc", "Berry"] @@ -392,6 +399,7 @@ class PokemonEmeraldWorld(World): if not item.advancement: item_category_counter.update([tag for tag in item.tags if tag in item_categories]) + self.item_pool = [] item_category_weights = [item_category_counter.get(category) for category in item_categories] item_category_weights = [weight if weight is not None else 0 for weight in item_category_weights] @@ -436,19 +444,10 @@ class PokemonEmeraldWorld(World): item_code = self.random.choice(fill_item_candidates_by_category[category]) item = self.create_item_by_code(item_code) - self.multiworld.itempool.append(item) + self.item_pool.append(item) - def set_rules(self) -> None: - from .rules import set_rules - set_rules(self) + self.multiworld.itempool += self.item_pool - def generate_basic(self) -> None: - # Create auth - # self.auth = self.random.randbytes(16) # Requires >=3.9 - self.auth = self.random.getrandbits(16 * 8).to_bytes(16, "little") - - randomize_types(self) - randomize_wild_encounters(self) set_free_fly(self) set_legendary_cave_entrances(self) @@ -475,9 +474,20 @@ class PokemonEmeraldWorld(World): if not self.options.key_items: convert_unrandomized_items_to_events(LocationCategory.KEY) - def pre_fill(self) -> None: - # Badges and HMs that are set to shuffle need to be placed at - # their own subset of locations + def set_rules(self): + from .rules import set_rules + set_rules(self) + + def connect_entrances(self): + randomize_wild_encounters(self) + self.shuffle_badges_hms() + # For entrance randomization, disconnect entrances here, randomize map, then + # undo badge/HM placement and re-shuffle them in the new map. + + def shuffle_badges_hms(self) -> None: + my_progression_items = [item for item in self.item_pool if item.advancement] + my_locations = list(self.get_locations()) + if self.options.badges == RandomizeBadges.option_shuffle: badge_locations: List[PokemonEmeraldLocation] badge_items: List[PokemonEmeraldItem] @@ -502,41 +512,20 @@ class PokemonEmeraldWorld(World): badge_priority["Knuckle Badge"] = 0 badge_items.sort(key=lambda item: badge_priority.get(item.name, 0)) - # Un-exclude badge locations, since we need to put progression items on them - for location in badge_locations: - location.progress_type = LocationProgressType.DEFAULT \ - if location.progress_type == LocationProgressType.EXCLUDED \ - else location.progress_type - - collection_state = self.multiworld.get_all_state(False) - - # If HM shuffle is on, HMs are not placed and not in the pool, so - # `get_all_state` did not contain them. Collect them manually for - # this fill. We know that they will be included in all state after - # this stage. + # Build state + state = CollectionState(self.multiworld) + for item in my_progression_items: + state.collect(item, True) + # If HM shuffle is on, HMs are neither placed in locations nor in + # the item pool, so we also need to collect them. if self.hm_shuffle_info is not None: for _, item in self.hm_shuffle_info: - collection_state.collect(item) + state.collect(item, True) + state.sweep_for_advancements(my_locations) - # In specific very constrained conditions, fill_restrictive may run - # out of swaps before it finds a valid solution if it gets unlucky. - # This is a band-aid until fill/swap can reliably find those solutions. - attempts_remaining = 2 - while attempts_remaining > 0: - attempts_remaining -= 1 - self.random.shuffle(badge_locations) - try: - fill_restrictive(self.multiworld, collection_state, badge_locations, badge_items, - single_player_placement=True, lock=True, allow_excluded=True) - break - except FillError as exc: - if attempts_remaining == 0: - raise exc + # Shuffle badges + self.fill_subset_with_retries(badge_items, badge_locations, state) - logging.debug(f"Failed to shuffle badges for player {self.player}. Retrying.") - continue - - # Badges are guaranteed to be either placed or in the multiworld's itempool now if self.options.hms == RandomizeHms.option_shuffle: hm_locations: List[PokemonEmeraldLocation] hm_items: List[PokemonEmeraldItem] @@ -559,33 +548,56 @@ class PokemonEmeraldWorld(World): if self.options.badges == RandomizeBadges.option_vanilla and \ self.options.require_flash in (DarkCavesRequireFlash.option_both, DarkCavesRequireFlash.option_only_granite_cave): hm_priority["HM05 Flash"] = 0 - hm_items.sort(key=lambda item: hm_priority.get(item.name, 0)) + hm_items.sort(key=lambda item: hm_priority.get(item.name, 0), reverse=True) - # Un-exclude HM locations, since we need to put progression items on them - for location in hm_locations: - location.progress_type = LocationProgressType.DEFAULT \ - if location.progress_type == LocationProgressType.EXCLUDED \ - else location.progress_type + # Build state + # Badges are either in the item pool, or already placed and collected during sweep + state = CollectionState(self.multiworld) + for item in my_progression_items: + state.collect(item, True) + state.sweep_for_advancements(my_locations) - collection_state = self.multiworld.get_all_state(False) + # Shuffle HMs + self.fill_subset_with_retries(hm_items, hm_locations, state) - # In specific very constrained conditions, fill_restrictive may run - # out of swaps before it finds a valid solution if it gets unlucky. - # This is a band-aid until fill/swap can reliably find those solutions. - attempts_remaining = 2 - while attempts_remaining > 0: - attempts_remaining -= 1 - self.random.shuffle(hm_locations) - try: - fill_restrictive(self.multiworld, collection_state, hm_locations, hm_items, - single_player_placement=True, lock=True, allow_excluded=True) - break - except FillError as exc: - if attempts_remaining == 0: - raise exc + def fill_subset_with_retries(self, items: list[PokemonEmeraldItem], locations: list[PokemonEmeraldLocation], state: CollectionState): + # Un-exclude locations, since we need to put progression items on them + for location in locations: + location.progress_type = LocationProgressType.DEFAULT \ + if location.progress_type == LocationProgressType.EXCLUDED \ + else location.progress_type - logging.debug(f"Failed to shuffle HMs for player {self.player}. Retrying.") - continue + # In specific very constrained conditions, `fill_restrictive` may run + # out of swaps before it finds a valid solution if it gets unlucky. + attempts_remaining = 2 + while attempts_remaining > 0: + attempts_remaining -= 1 + locations_copy = locations.copy() + items_copy = items.copy() + self.random.shuffle(locations_copy) + try: + fill_restrictive(self.multiworld, state, locations_copy, items_copy, single_player_placement=True, + lock=True) + break + except FillError as exc: + if attempts_remaining <= 0: + raise exc + + # Undo partial item placement + for location in locations: + location.locked = False + if location.item is not None: + location.item.location = None + location.item = None + + logging.debug(f"Failed to shuffle items for player {self.player} ({self.player_name}). Retrying.") + continue + + def generate_basic(self) -> None: + # Create auth + self.auth = self.random.randbytes(16) + + randomize_types(self) def generate_output(self, output_directory: str) -> None: self.modified_trainers = copy.deepcopy(emerald_data.trainers) From ca08e4b950910e9383b68fd656f1c649da1ba058 Mon Sep 17 00:00:00 2001 From: Mysteryem Date: Tue, 1 Apr 2025 17:14:47 +0100 Subject: [PATCH 18/53] Super Metroid: Replace random module with world random in variaRandomizer (#4429) --- worlds/sm/__init__.py | 10 +-- .../sm/variaRandomizer/graph/graph_utils.py | 26 +++++--- worlds/sm/variaRandomizer/rando/Choice.py | 22 +++---- worlds/sm/variaRandomizer/rando/Filler.py | 12 ++-- .../sm/variaRandomizer/rando/GraphBuilder.py | 17 ++--- worlds/sm/variaRandomizer/rando/Items.py | 64 ++++++++++--------- worlds/sm/variaRandomizer/rando/RandoExec.py | 17 ++--- .../sm/variaRandomizer/rando/RandoServices.py | 18 +++--- .../sm/variaRandomizer/rando/RandoSettings.py | 15 ++--- worlds/sm/variaRandomizer/rando/RandoSetup.py | 13 ++-- .../sm/variaRandomizer/rando/Restrictions.py | 4 +- worlds/sm/variaRandomizer/randomizer.py | 41 ++++++------ worlds/sm/variaRandomizer/rom/rompatcher.py | 11 ++-- .../sm/variaRandomizer/utils/doorsmanager.py | 7 +- worlds/sm/variaRandomizer/utils/objectives.py | 13 ++-- worlds/sm/variaRandomizer/utils/utils.py | 6 +- 16 files changed, 154 insertions(+), 142 deletions(-) diff --git a/worlds/sm/__init__.py b/worlds/sm/__init__.py index 5d53270d61..bc8dcd6114 100644 --- a/worlds/sm/__init__.py +++ b/worlds/sm/__init__.py @@ -124,7 +124,7 @@ class SMWorld(World): Logic.factory('vanilla') dummy_rom_file = Utils.user_path(SMSettings.RomFile.copy_to) # actual rom set in generate_output - self.variaRando = VariaRandomizer(self.options, dummy_rom_file, self.player) + self.variaRando = VariaRandomizer(self.options, dummy_rom_file, self.player, self.multiworld.seed, self.random) self.multiworld.state.smbm[self.player] = SMBoolManager(self.player, self.variaRando.maxDifficulty) # keeps Nothing items local so no player will ever pickup Nothing @@ -314,11 +314,11 @@ class SMWorld(World): raise KeyError(f"Item {name} for {self.player_name} is invalid.") def get_filler_item_name(self) -> str: - if self.multiworld.random.randint(0, 100) < self.options.minor_qty.value: + if self.random.randint(0, 100) < self.options.minor_qty.value: power_bombs = self.options.power_bomb_qty.value missiles = self.options.missile_qty.value super_missiles = self.options.super_qty.value - roll = self.multiworld.random.randint(1, power_bombs + missiles + super_missiles) + roll = self.random.randint(1, power_bombs + missiles + super_missiles) if roll <= power_bombs: return "Power Bomb" elif roll <= power_bombs + missiles: @@ -340,8 +340,8 @@ class SMWorld(World): else: nonChozoLoc.append(loc) - self.multiworld.random.shuffle(nonChozoLoc) - self.multiworld.random.shuffle(chozoLoc) + self.random.shuffle(nonChozoLoc) + self.random.shuffle(chozoLoc) missingCount = len(self.NothingPool) - len(nonChozoLoc) locations = nonChozoLoc if (missingCount > 0): diff --git a/worlds/sm/variaRandomizer/graph/graph_utils.py b/worlds/sm/variaRandomizer/graph/graph_utils.py index 3625334346..b2b889fd2b 100644 --- a/worlds/sm/variaRandomizer/graph/graph_utils.py +++ b/worlds/sm/variaRandomizer/graph/graph_utils.py @@ -1,5 +1,4 @@ import copy -import random from ..logic.logic import Logic from ..utils.parameters import Knows from ..graph.location import locationsDict @@ -136,7 +135,8 @@ class GraphUtils: refused[apName] = cause return ret, refused - def updateLocClassesStart(startGraphArea, split, possibleMajLocs, preserveMajLocs, nLocs): + @staticmethod + def updateLocClassesStart(startGraphArea, split, possibleMajLocs, preserveMajLocs, nLocs, random): locs = locationsDict preserveMajLocs = [locs[locName] for locName in preserveMajLocs if locs[locName].isClass(split)] possLocs = [locs[locName] for locName in possibleMajLocs][:nLocs] @@ -160,7 +160,8 @@ class GraphUtils: ap = getAccessPoint(startApName) return ap.Start['patches'] if 'patches' in ap.Start else [] - def createBossesTransitions(): + @staticmethod + def createBossesTransitions(random): transitions = vanillaBossesTransitions def isVanilla(): for t in vanillaBossesTransitions: @@ -180,13 +181,15 @@ class GraphUtils: transitions.append((src,dst)) return transitions - def createAreaTransitions(lightAreaRando=False): + @staticmethod + def createAreaTransitions(lightAreaRando=False, *, random): if lightAreaRando: - return GraphUtils.createLightAreaTransitions() + return GraphUtils.createLightAreaTransitions(random=random) else: - return GraphUtils.createRegularAreaTransitions() + return GraphUtils.createRegularAreaTransitions(random=random) - def createRegularAreaTransitions(apList=None, apPred=None): + @staticmethod + def createRegularAreaTransitions(apList=None, apPred=None, *, random): if apList is None: apList = Logic.accessPoints if apPred is None: @@ -239,7 +242,8 @@ class GraphUtils: transitions.append((ap.Name, ap.Name)) # crateria can be forced in corner cases - def createMinimizerTransitions(startApName, locLimit, forcedAreas=None): + @staticmethod + def createMinimizerTransitions(startApName, locLimit, forcedAreas=None, *, random): if forcedAreas is None: forcedAreas = [] if startApName == 'Ceres': @@ -316,7 +320,8 @@ class GraphUtils: GraphUtils.log.debug("FINAL MINIMIZER areas: "+str(areas)) return transitions - def createLightAreaTransitions(): + @staticmethod + def createLightAreaTransitions(random): # group APs by area aps = {} totalCount = 0 @@ -407,7 +412,8 @@ class GraphUtils: return rooms - def escapeAnimalsTransitions(graph, possibleTargets, firstEscape): + @staticmethod + def escapeAnimalsTransitions(graph, possibleTargets, firstEscape, random): n = len(possibleTargets) assert (n < 4 and firstEscape is not None) or (n <= 4 and firstEscape is None), "Invalid possibleTargets list: " + str(possibleTargets) GraphUtils.log.debug("escapeAnimalsTransitions. possibleTargets="+str(possibleTargets)+", firstEscape="+str(firstEscape)) diff --git a/worlds/sm/variaRandomizer/rando/Choice.py b/worlds/sm/variaRandomizer/rando/Choice.py index b4f4166f76..72af3d809a 100644 --- a/worlds/sm/variaRandomizer/rando/Choice.py +++ b/worlds/sm/variaRandomizer/rando/Choice.py @@ -1,4 +1,3 @@ -import random from ..utils import log from ..utils.utils import getRangeDict, chooseFromRange from ..rando.ItemLocContainer import ItemLocation @@ -23,8 +22,9 @@ class Choice(object): # simple random choice, that chooses an item first, then a locatio to put it in class ItemThenLocChoice(Choice): - def __init__(self, restrictions): + def __init__(self, restrictions, random): super(ItemThenLocChoice, self).__init__(restrictions) + self.random = random def chooseItemLoc(self, itemLocDict, isProg): itemList = self.getItemList(itemLocDict) @@ -49,7 +49,7 @@ class ItemThenLocChoice(Choice): return self.chooseItemRandom(itemList) def chooseItemRandom(self, itemList): - return random.choice(itemList) + return self.random.choice(itemList) def chooseLocation(self, locList, item, isProg): if len(locList) == 0: @@ -63,12 +63,12 @@ class ItemThenLocChoice(Choice): return self.chooseLocationRandom(locList) def chooseLocationRandom(self, locList): - return random.choice(locList) + return self.random.choice(locList) # Choice specialization for prog speed based filler class ItemThenLocChoiceProgSpeed(ItemThenLocChoice): - def __init__(self, restrictions, progSpeedParams, distanceProp, services): - super(ItemThenLocChoiceProgSpeed, self).__init__(restrictions) + def __init__(self, restrictions, progSpeedParams, distanceProp, services, random): + super(ItemThenLocChoiceProgSpeed, self).__init__(restrictions, random) self.progSpeedParams = progSpeedParams self.distanceProp = distanceProp self.services = services @@ -104,7 +104,7 @@ class ItemThenLocChoiceProgSpeed(ItemThenLocChoice): if self.restrictions.isLateMorph() and canRollback and len(itemLocDict) == 1: item, locList = list(itemLocDict.items())[0] if item.Type == 'Morph': - morphLocs = self.restrictions.lateMorphCheck(container, locList) + morphLocs = self.restrictions.lateMorphCheck(container, locList, self.random) if morphLocs is not None: itemLocDict[item] = morphLocs else: @@ -115,7 +115,7 @@ class ItemThenLocChoiceProgSpeed(ItemThenLocChoice): assert len(locs) == 1 and locs[0].Name == item.Name return ItemLocation(item, locs[0]) # late doors check for random door colors - if self.restrictions.isLateDoors() and random.random() < self.lateDoorsProb: + if self.restrictions.isLateDoors() and self.random.random() < self.lateDoorsProb: self.processLateDoors(itemLocDict, ap, container) self.progressionItemLocs = progressionItemLocs self.ap = ap @@ -145,14 +145,14 @@ class ItemThenLocChoiceProgSpeed(ItemThenLocChoice): def chooseLocationProg(self, locs, item): locs = self.getLocsSpreadProgression(locs) - random.shuffle(locs) + self.random.shuffle(locs) ret = self.getChooseFunc(self.chooseLocRanges, self.chooseLocFuncs)(locs) self.log.debug('chooseLocationProg. ret='+ret.Name) return ret # get choose function from a weighted dict def getChooseFunc(self, rangeDict, funcDict): - v = chooseFromRange(rangeDict) + v = chooseFromRange(rangeDict, self.random) return funcDict[v] @@ -209,6 +209,6 @@ class ItemThenLocChoiceProgSpeed(ItemThenLocChoice): for i in range(len(availableLocations)): loc = availableLocations[i] d = distances[i] - if d == maxDist or random.random() >= self.spreadProb: + if d == maxDist or self.random.random() >= self.spreadProb: locs.append(loc) return locs diff --git a/worlds/sm/variaRandomizer/rando/Filler.py b/worlds/sm/variaRandomizer/rando/Filler.py index 00caa7e630..3fab9d39d4 100644 --- a/worlds/sm/variaRandomizer/rando/Filler.py +++ b/worlds/sm/variaRandomizer/rando/Filler.py @@ -1,5 +1,5 @@ -import copy, time, random +import copy, time from ..utils import log from ..logic.cache import RequestCache from ..rando.RandoServices import RandoServices @@ -15,11 +15,11 @@ from ..graph.graph_utils import GraphUtils # item pool is not empty). # entry point is generateItems class Filler(object): - def __init__(self, startAP, graph, restrictions, emptyContainer, endDate=infinity): + def __init__(self, startAP, graph, restrictions, emptyContainer, endDate=infinity, *, random): self.startAP = startAP self.cache = RequestCache() self.graph = graph - self.services = RandoServices(graph, restrictions, self.cache) + self.services = RandoServices(graph, restrictions, self.cache, random=random) self.restrictions = restrictions self.settings = restrictions.settings self.endDate = endDate @@ -108,9 +108,9 @@ class Filler(object): # very simple front fill algorithm with no rollback and no "softlock checks" (== dessy algorithm) class FrontFiller(Filler): - def __init__(self, startAP, graph, restrictions, emptyContainer, endDate=infinity): - super(FrontFiller, self).__init__(startAP, graph, restrictions, emptyContainer, endDate) - self.choice = ItemThenLocChoice(restrictions) + def __init__(self, startAP, graph, restrictions, emptyContainer, endDate=infinity, *, random): + super(FrontFiller, self).__init__(startAP, graph, restrictions, emptyContainer, endDate, random=random) + self.choice = ItemThenLocChoice(restrictions, random) self.stdStart = GraphUtils.isStandardStart(self.startAP) def isEarlyGame(self): diff --git a/worlds/sm/variaRandomizer/rando/GraphBuilder.py b/worlds/sm/variaRandomizer/rando/GraphBuilder.py index 88b539e7f0..7fb8802d54 100644 --- a/worlds/sm/variaRandomizer/rando/GraphBuilder.py +++ b/worlds/sm/variaRandomizer/rando/GraphBuilder.py @@ -1,5 +1,5 @@ -import random, copy +import copy from ..utils import log from ..graph.graph_utils import GraphUtils, vanillaTransitions, vanillaBossesTransitions, escapeSource, escapeTargets, graphAreas, getAccessPoint from ..logic.logic import Logic @@ -11,13 +11,14 @@ from collections import defaultdict # creates graph and handles randomized escape class GraphBuilder(object): - def __init__(self, graphSettings): + def __init__(self, graphSettings, random): self.graphSettings = graphSettings self.areaRando = graphSettings.areaRando self.bossRando = graphSettings.bossRando self.escapeRando = graphSettings.escapeRando self.minimizerN = graphSettings.minimizerN self.log = log.get('GraphBuilder') + self.random = random # builds everything but escape transitions def createGraph(self, maxDiff): @@ -48,18 +49,18 @@ class GraphBuilder(object): objForced = forcedAreas.intersection(escAreas) escAreasList = sorted(list(escAreas)) while len(objForced) < n and len(escAreasList) > 0: - objForced.add(escAreasList.pop(random.randint(0, len(escAreasList)-1))) + objForced.add(escAreasList.pop(self.random.randint(0, len(escAreasList)-1))) forcedAreas = forcedAreas.union(objForced) - transitions = GraphUtils.createMinimizerTransitions(self.graphSettings.startAP, self.minimizerN, sorted(list(forcedAreas))) + transitions = GraphUtils.createMinimizerTransitions(self.graphSettings.startAP, self.minimizerN, sorted(list(forcedAreas)), random=self.random) else: if not self.bossRando: transitions += vanillaBossesTransitions else: - transitions += GraphUtils.createBossesTransitions() + transitions += GraphUtils.createBossesTransitions(self.random) if not self.areaRando: transitions += vanillaTransitions else: - transitions += GraphUtils.createAreaTransitions(self.graphSettings.lightAreaRando) + transitions += GraphUtils.createAreaTransitions(self.graphSettings.lightAreaRando, random=self.random) ret = AccessGraph(Logic.accessPoints, transitions, self.graphSettings.dotFile) Objectives.objDict[self.graphSettings.player].setGraph(ret, maxDiff) return ret @@ -100,7 +101,7 @@ class GraphBuilder(object): self.escapeTimer(graph, paths, self.areaRando or escapeTrigger is not None) self.log.debug("escapeGraph: ({}, {}) timer: {}".format(escapeSource, dst, graph.EscapeAttributes['Timer'])) # animals - GraphUtils.escapeAnimalsTransitions(graph, possibleTargets, dst) + GraphUtils.escapeAnimalsTransitions(graph, possibleTargets, dst, self.random) return True def _getTargets(self, sm, graph, maxDiff): @@ -110,7 +111,7 @@ class GraphBuilder(object): if len(possibleTargets) == 0: self.log.debug("Can't randomize escape, fallback to vanilla") possibleTargets.append('Climb Bottom Left') - random.shuffle(possibleTargets) + self.random.shuffle(possibleTargets) return possibleTargets def getPossibleEscapeTargets(self, emptyContainer, graph, maxDiff): diff --git a/worlds/sm/variaRandomizer/rando/Items.py b/worlds/sm/variaRandomizer/rando/Items.py index ec58b4782a..612bc36b64 100644 --- a/worlds/sm/variaRandomizer/rando/Items.py +++ b/worlds/sm/variaRandomizer/rando/Items.py @@ -1,6 +1,6 @@ from ..utils.utils import randGaussBounds, getRangeDict, chooseFromRange from ..utils import log -import logging, copy, random +import logging, copy class Item: __slots__ = ( 'Category', 'Class', 'Name', 'Code', 'Type', 'BeamBits', 'ItemBits', 'Id' ) @@ -335,7 +335,7 @@ class ItemManager: itemCode = item.Code + modifier return itemCode - def __init__(self, majorsSplit, qty, sm, nLocs, bossesItems, maxDiff): + def __init__(self, majorsSplit, qty, sm, nLocs, bossesItems, maxDiff, random): self.qty = qty self.sm = sm self.majorsSplit = majorsSplit @@ -344,6 +344,7 @@ class ItemManager: self.maxDiff = maxDiff self.majorClass = 'Chozo' if majorsSplit == 'Chozo' else 'Major' self.itemPool = [] + self.random = random def newItemPool(self, addBosses=True): self.itemPool = [] @@ -386,7 +387,7 @@ class ItemManager: return ItemManager.Items[itemType].withClass(itemClass) def createItemPool(self, exclude=None): - itemPoolGenerator = ItemPoolGenerator.factory(self.majorsSplit, self, self.qty, self.sm, exclude, self.nLocs, self.maxDiff) + itemPoolGenerator = ItemPoolGenerator.factory(self.majorsSplit, self, self.qty, self.sm, exclude, self.nLocs, self.maxDiff, self.random) self.itemPool = itemPoolGenerator.getItemPool() @staticmethod @@ -402,20 +403,20 @@ class ItemPoolGenerator(object): nbBosses = 9 @staticmethod - def factory(majorsSplit, itemManager, qty, sm, exclude, nLocs, maxDiff): + def factory(majorsSplit, itemManager, qty, sm, exclude, nLocs, maxDiff, random): if majorsSplit == 'Chozo': - return ItemPoolGeneratorChozo(itemManager, qty, sm, maxDiff) + return ItemPoolGeneratorChozo(itemManager, qty, sm, maxDiff, random) elif majorsSplit == 'Plando': - return ItemPoolGeneratorPlando(itemManager, qty, sm, exclude, nLocs, maxDiff) + return ItemPoolGeneratorPlando(itemManager, qty, sm, exclude, nLocs, maxDiff, random) elif nLocs == ItemPoolGenerator.maxLocs: if majorsSplit == "Scavenger": - return ItemPoolGeneratorScavenger(itemManager, qty, sm, maxDiff) + return ItemPoolGeneratorScavenger(itemManager, qty, sm, maxDiff, random) else: - return ItemPoolGeneratorMajors(itemManager, qty, sm, maxDiff) + return ItemPoolGeneratorMajors(itemManager, qty, sm, maxDiff, random) else: - return ItemPoolGeneratorMinimizer(itemManager, qty, sm, nLocs, maxDiff) + return ItemPoolGeneratorMinimizer(itemManager, qty, sm, nLocs, maxDiff, random) - def __init__(self, itemManager, qty, sm, maxDiff): + def __init__(self, itemManager, qty, sm, maxDiff, random): self.itemManager = itemManager self.qty = qty self.sm = sm @@ -423,12 +424,13 @@ class ItemPoolGenerator(object): self.maxEnergy = 18 # 14E, 4R self.maxDiff = maxDiff self.log = log.get('ItemPool') + self.random = random def isUltraSparseNoTanks(self): # if low stuff botwoon is not known there is a hard energy req of one tank, even # with both suits lowStuffBotwoon = self.sm.knowsLowStuffBotwoon() - return random.random() < 0.5 and (lowStuffBotwoon.bool == True and lowStuffBotwoon.difficulty <= self.maxDiff) + return self.random.random() < 0.5 and (lowStuffBotwoon.bool == True and lowStuffBotwoon.difficulty <= self.maxDiff) def calcMaxMinors(self): pool = self.itemManager.getItemPool() @@ -464,7 +466,7 @@ class ItemPoolGenerator(object): rangeDict = getRangeDict(ammoQty) self.log.debug("rangeDict: {}".format(rangeDict)) while len(self.itemManager.getItemPool()) < maxItems: - item = chooseFromRange(rangeDict) + item = chooseFromRange(rangeDict, self.random) self.itemManager.addMinor(item) else: minorsTypes = ['Missile', 'Super', 'PowerBomb'] @@ -522,7 +524,7 @@ class ItemPoolGeneratorChozo(ItemPoolGenerator): # no etank nor reserve self.itemManager.removeItem('ETank') self.itemManager.addItem('NoEnergy', 'Chozo') - elif random.random() < 0.5: + elif self.random.random() < 0.5: # replace only etank with reserve self.itemManager.removeItem('ETank') self.itemManager.addItem('Reserve', 'Chozo') @@ -535,9 +537,9 @@ class ItemPoolGeneratorChozo(ItemPoolGenerator): # 4-6 # already 3E and 1R alreadyInPool = 4 - rest = randGaussBounds(2, 5) + rest = randGaussBounds(self.random, 2, 5) if rest >= 1: - if random.random() < 0.5: + if self.random.random() < 0.5: self.itemManager.addItem('Reserve', 'Minor') else: self.itemManager.addItem('ETank', 'Minor') @@ -550,13 +552,13 @@ class ItemPoolGeneratorChozo(ItemPoolGenerator): # 8-12 # add up to 3 Reserves or ETanks (cannot add more than 3 reserves) for i in range(3): - if random.random() < 0.5: + if self.random.random() < 0.5: self.itemManager.addItem('Reserve', 'Minor') else: self.itemManager.addItem('ETank', 'Minor') # 7 already in the pool (3 E, 1 R, + the previous 3) alreadyInPool = 7 - rest = 1 + randGaussBounds(4, 3.7) + rest = 1 + randGaussBounds(self.random, 4, 3.7) for i in range(rest): self.itemManager.addItem('ETank', 'Minor') # fill the rest with NoEnergy @@ -581,10 +583,10 @@ class ItemPoolGeneratorChozo(ItemPoolGenerator): return self.itemManager.getItemPool() class ItemPoolGeneratorMajors(ItemPoolGenerator): - def __init__(self, itemManager, qty, sm, maxDiff): - super(ItemPoolGeneratorMajors, self).__init__(itemManager, qty, sm, maxDiff) - self.sparseRest = 1 + randGaussBounds(2, 5) - self.mediumRest = 3 + randGaussBounds(4, 3.7) + def __init__(self, itemManager, qty, sm, maxDiff, random): + super(ItemPoolGeneratorMajors, self).__init__(itemManager, qty, sm, maxDiff, random) + self.sparseRest = 1 + randGaussBounds(self.random,2, 5) + self.mediumRest = 3 + randGaussBounds(self.random, 4, 3.7) self.ultraSparseNoTanks = self.isUltraSparseNoTanks() def addNoEnergy(self): @@ -609,7 +611,7 @@ class ItemPoolGeneratorMajors(ItemPoolGenerator): # no energy at all self.addNoEnergy() else: - if random.random() < 0.5: + if self.random.random() < 0.5: self.itemManager.addItem('ETank') else: self.itemManager.addItem('Reserve') @@ -620,7 +622,7 @@ class ItemPoolGeneratorMajors(ItemPoolGenerator): elif energyQty == 'sparse': # 4-6 - if random.random() < 0.5: + if self.random.random() < 0.5: self.itemManager.addItem('Reserve') else: self.itemManager.addItem('ETank') @@ -639,7 +641,7 @@ class ItemPoolGeneratorMajors(ItemPoolGenerator): alreadyInPool = 2 n = getE(3) for i in range(n): - if random.random() < 0.5: + if self.random.random() < 0.5: self.itemManager.addItem('Reserve') else: self.itemManager.addItem('ETank') @@ -676,15 +678,15 @@ class ItemPoolGeneratorMajors(ItemPoolGenerator): return self.itemManager.getItemPool() class ItemPoolGeneratorScavenger(ItemPoolGeneratorMajors): - def __init__(self, itemManager, qty, sm, maxDiff): - super(ItemPoolGeneratorScavenger, self).__init__(itemManager, qty, sm, maxDiff) + def __init__(self, itemManager, qty, sm, maxDiff, random): + super(ItemPoolGeneratorScavenger, self).__init__(itemManager, qty, sm, maxDiff, random) def addNoEnergy(self): self.itemManager.addItem('Nothing') class ItemPoolGeneratorMinimizer(ItemPoolGeneratorMajors): - def __init__(self, itemManager, qty, sm, nLocs, maxDiff): - super(ItemPoolGeneratorMinimizer, self).__init__(itemManager, qty, sm, maxDiff) + def __init__(self, itemManager, qty, sm, nLocs, maxDiff, random): + super(ItemPoolGeneratorMinimizer, self).__init__(itemManager, qty, sm, maxDiff, random) self.maxItems = nLocs self.calcMaxAmmo() nMajors = len([itemName for itemName,item in ItemManager.Items.items() if item.Class == 'Major' and item.Category != 'Energy']) @@ -716,8 +718,8 @@ class ItemPoolGeneratorMinimizer(ItemPoolGeneratorMajors): self.log.debug("maxEnergy: "+str(self.maxEnergy)) class ItemPoolGeneratorPlando(ItemPoolGenerator): - def __init__(self, itemManager, qty, sm, exclude, nLocs, maxDiff): - super(ItemPoolGeneratorPlando, self).__init__(itemManager, qty, sm, maxDiff) + def __init__(self, itemManager, qty, sm, exclude, nLocs, maxDiff, random): + super(ItemPoolGeneratorPlando, self).__init__(itemManager, qty, sm, maxDiff, random) # in exclude dict: # in alreadyPlacedItems: # dict of 'itemType: count' of items already added in the plando. @@ -805,7 +807,7 @@ class ItemPoolGeneratorPlando(ItemPoolGenerator): if ammoQty: rangeDict = getRangeDict(ammoQty) while len(self.itemManager.getItemPool()) < maxItems and remain > 0: - item = chooseFromRange(rangeDict) + item = chooseFromRange(rangeDict, self.random) self.itemManager.addMinor(item) remain -= 1 diff --git a/worlds/sm/variaRandomizer/rando/RandoExec.py b/worlds/sm/variaRandomizer/rando/RandoExec.py index f799252f97..c50e0691e9 100644 --- a/worlds/sm/variaRandomizer/rando/RandoExec.py +++ b/worlds/sm/variaRandomizer/rando/RandoExec.py @@ -1,4 +1,4 @@ -import sys, random, time +import sys, time from ..utils import log from ..logic.logic import Logic @@ -14,7 +14,7 @@ from ..utils.doorsmanager import DoorsManager # entry point for rando execution ("randomize" method) class RandoExec(object): - def __init__(self, seedName, vcr, randoSettings, graphSettings, player): + def __init__(self, seedName, vcr, randoSettings, graphSettings, player, random): self.errorMsg = "" self.seedName = seedName self.vcr = vcr @@ -22,6 +22,7 @@ class RandoExec(object): self.graphSettings = graphSettings self.log = log.get('RandoExec') self.player = player + self.random = random # processes settings to : # - create Restrictions and GraphBuilder objects @@ -31,7 +32,7 @@ class RandoExec(object): vcr = VCR(self.seedName, 'rando') if self.vcr == True else None self.errorMsg = "" split = self.randoSettings.restrictions['MajorMinor'] - self.graphBuilder = GraphBuilder(self.graphSettings) + self.graphBuilder = GraphBuilder(self.graphSettings, self.random) container = None i = 0 attempts = 500 if self.graphSettings.areaRando or self.graphSettings.doorsColorsRando or split == 'Scavenger' else 1 @@ -43,10 +44,10 @@ class RandoExec(object): while container is None and i < attempts and now <= endDate: self.restrictions = Restrictions(self.randoSettings) if self.graphSettings.doorsColorsRando == True: - DoorsManager.randomize(self.graphSettings.allowGreyDoors, self.player) + DoorsManager.randomize(self.graphSettings.allowGreyDoors, self.player, self.random) self.areaGraph = self.graphBuilder.createGraph(self.randoSettings.maxDiff) - services = RandoServices(self.areaGraph, self.restrictions) - setup = RandoSetup(self.graphSettings, Logic.locations[:], services, self.player) + services = RandoServices(self.areaGraph, self.restrictions, random=self.random) + setup = RandoSetup(self.graphSettings, Logic.locations[:], services, self.player, self.random) self.setup = setup container = setup.createItemLocContainer(endDate, vcr) if container is None: @@ -78,7 +79,7 @@ class RandoExec(object): n = nMaj elif split == 'Chozo': n = nChozo - GraphUtils.updateLocClassesStart(startAP.GraphArea, split, possibleMajLocs, preserveMajLocs, n) + GraphUtils.updateLocClassesStart(startAP.GraphArea, split, possibleMajLocs, preserveMajLocs, n, self.random) def postProcessItemLocs(self, itemLocs, hide): # hide some items like in dessy's @@ -89,7 +90,7 @@ class RandoExec(object): if (item.Category != "Nothing" and loc.CanHidden == True and loc.Visibility == 'Visible'): - if bool(random.getrandbits(1)) == True: + if bool(self.random.getrandbits(1)) == True: loc.Visibility = 'Hidden' # put nothing in unfilled locations filledLocNames = [il.Location.Name for il in itemLocs] diff --git a/worlds/sm/variaRandomizer/rando/RandoServices.py b/worlds/sm/variaRandomizer/rando/RandoServices.py index a3ad1f39b5..e060c356aa 100644 --- a/worlds/sm/variaRandomizer/rando/RandoServices.py +++ b/worlds/sm/variaRandomizer/rando/RandoServices.py @@ -1,5 +1,4 @@ - -import copy, random, sys, logging, os +import copy, sys, logging, os from enum import Enum, unique from ..utils import log from ..utils.parameters import infinity @@ -19,12 +18,13 @@ class ComebackCheckType(Enum): # collection of stateless services to be used mainly by fillers class RandoServices(object): - def __init__(self, graph, restrictions, cache=None): + def __init__(self, graph, restrictions, cache=None, *, random): self.restrictions = restrictions self.settings = restrictions.settings self.areaGraph = graph self.cache = cache self.log = log.get('RandoServices') + self.random = random @staticmethod def printProgress(s): @@ -217,7 +217,7 @@ class RandoServices(object): # choose a morph item location in that context morphItemLoc = ItemLocation( morph, - random.choice(morphLocs) + self.random.choice(morphLocs) ) # acquire morph in new context and see if we can still open new locs newAP = self.collect(ap, containerCpy, morphItemLoc) @@ -232,7 +232,7 @@ class RandoServices(object): if morphLocItem is None or len(itemLocDict) == 1: # no morph, or it is the only possibility: nothing to do return - morphLocs = self.restrictions.lateMorphCheck(container, itemLocDict[morphLocItem]) + morphLocs = self.restrictions.lateMorphCheck(container, itemLocDict[morphLocItem], self.random) if morphLocs is not None: itemLocDict[morphLocItem] = morphLocs else: @@ -380,10 +380,10 @@ class RandoServices(object): (itemLocDict, isProg) = self.getPossiblePlacements(ap, container, ComebackCheckType.NoCheck) assert not isProg items = list(itemLocDict.keys()) - random.shuffle(items) + self.random.shuffle(items) for item in items: cont = copy.copy(container) - loc = random.choice(itemLocDict[item]) + loc = self.random.choice(itemLocDict[item]) itemLoc1 = ItemLocation(item, loc) self.log.debug("itemLoc1 attempt: "+getItemLocStr(itemLoc1)) newAP = self.collect(ap, cont, itemLoc1) @@ -391,8 +391,8 @@ class RandoServices(object): self.cache.reset() (ild, isProg) = self.getPossiblePlacements(newAP, cont, ComebackCheckType.NoCheck) if isProg: - item2 = random.choice(list(ild.keys())) - itemLoc2 = ItemLocation(item2, random.choice(ild[item2])) + item2 = self.random.choice(list(ild.keys())) + itemLoc2 = ItemLocation(item2, self.random.choice(ild[item2])) self.log.debug("itemLoc2: "+getItemLocStr(itemLoc2)) return (itemLoc1, itemLoc2) return None diff --git a/worlds/sm/variaRandomizer/rando/RandoSettings.py b/worlds/sm/variaRandomizer/rando/RandoSettings.py index 418688f1e5..b0e9bb4c0d 100644 --- a/worlds/sm/variaRandomizer/rando/RandoSettings.py +++ b/worlds/sm/variaRandomizer/rando/RandoSettings.py @@ -1,5 +1,4 @@ - -import sys, random +import sys from collections import defaultdict from ..rando.Items import ItemManager from ..utils.utils import getRangeDict, chooseFromRange @@ -32,11 +31,11 @@ class RandoSettings(object): def isPlandoRando(self): return self.PlandoOptions is not None - def getItemManager(self, smbm, nLocs, bossesItems): + def getItemManager(self, smbm, nLocs, bossesItems, random): if not self.isPlandoRando(): - return ItemManager(self.restrictions['MajorMinor'], self.qty, smbm, nLocs, bossesItems, self.maxDiff) + return ItemManager(self.restrictions['MajorMinor'], self.qty, smbm, nLocs, bossesItems, self.maxDiff, random) else: - return ItemManager('Plando', self.qty, smbm, nLocs, bossesItems, self.maxDiff) + return ItemManager('Plando', self.qty, smbm, nLocs, bossesItems, self.maxDiff, random) def getExcludeItems(self, locations): if not self.isPlandoRando(): @@ -94,7 +93,7 @@ class ProgSpeedParameters(object): self.restrictions = restrictions self.nLocs = nLocs - def getVariableSpeed(self): + def getVariableSpeed(self, random): ranges = getRangeDict({ 'slowest':7, 'slow':20, @@ -102,7 +101,7 @@ class ProgSpeedParameters(object): 'fast':27, 'fastest':11 }) - return chooseFromRange(ranges) + return chooseFromRange(ranges, random) def getMinorHelpProb(self, progSpeed): if self.restrictions.split != 'Major': @@ -134,7 +133,7 @@ class ProgSpeedParameters(object): def isSlow(self, progSpeed): return progSpeed == "slow" or (progSpeed == "slowest" and self.restrictions.split == "Chozo") - def getItemLimit(self, progSpeed): + def getItemLimit(self, progSpeed, random): itemLimit = self.nLocs if self.isSlow(progSpeed): itemLimit = int(self.nLocs*0.209) # 21 for 105 diff --git a/worlds/sm/variaRandomizer/rando/RandoSetup.py b/worlds/sm/variaRandomizer/rando/RandoSetup.py index 943e3fe5f3..eb64c0d3b4 100644 --- a/worlds/sm/variaRandomizer/rando/RandoSetup.py +++ b/worlds/sm/variaRandomizer/rando/RandoSetup.py @@ -1,4 +1,4 @@ -import copy, random +import copy from ..utils import log from ..utils.utils import randGaussBounds @@ -16,8 +16,9 @@ from ..rom.rom_patches import RomPatches # checks init conditions for the randomizer: processes super fun settings, graph, start location, special restrictions # the entry point is createItemLocContainer class RandoSetup(object): - def __init__(self, graphSettings, locations, services, player): + def __init__(self, graphSettings, locations, services, player, random): self.sm = SMBoolManager(player, services.settings.maxDiff) + self.random = random self.settings = services.settings self.graphSettings = graphSettings self.startAP = graphSettings.startAP @@ -31,7 +32,7 @@ class RandoSetup(object): # print("nLocs Setup: "+str(len(self.locations))) # in minimizer we can have some missing boss locs bossesItems = [loc.BossItemType for loc in self.locations if loc.isBoss()] - self.itemManager = self.settings.getItemManager(self.sm, len(self.locations), bossesItems) + self.itemManager = self.settings.getItemManager(self.sm, len(self.locations), bossesItems, random) self.forbiddenItems = [] self.restrictedLocs = [] self.lastRestricted = [] @@ -165,7 +166,7 @@ class RandoSetup(object): return True self.log.debug("********* PRE RANDO START") container = copy.copy(self.container) - filler = FrontFiller(self.startAP, self.areaGraph, self.restrictions, container) + filler = FrontFiller(self.startAP, self.areaGraph, self.restrictions, container, random=self.random) condition = filler.createStepCountCondition(4) (isStuck, itemLocations, progItems) = filler.generateItems(condition) self.log.debug("********* PRE RANDO END") @@ -345,9 +346,9 @@ class RandoSetup(object): def getForbiddenItemsFromList(self, itemList): self.log.debug('getForbiddenItemsFromList: ' + str(itemList)) remove = [] - n = randGaussBounds(len(itemList)) + n = randGaussBounds(self.random, len(itemList)) for i in range(n): - idx = random.randint(0, len(itemList) - 1) + idx = self.random.randint(0, len(itemList) - 1) item = itemList.pop(idx) if item is not None: remove.append(item) diff --git a/worlds/sm/variaRandomizer/rando/Restrictions.py b/worlds/sm/variaRandomizer/rando/Restrictions.py index fabdfea455..4f9de53d7d 100644 --- a/worlds/sm/variaRandomizer/rando/Restrictions.py +++ b/worlds/sm/variaRandomizer/rando/Restrictions.py @@ -1,4 +1,4 @@ -import copy, random +import copy from ..utils import log from ..graph.graph_utils import getAccessPoint from ..rando.ItemLocContainer import getLocListStr @@ -112,7 +112,7 @@ class Restrictions(object): return item.Class == "Minor" # return True if we can keep morph as a possibility - def lateMorphCheck(self, container, possibleLocs): + def lateMorphCheck(self, container, possibleLocs, random): # the closer we get to the limit the higher the chances of allowing morph proba = random.randint(0, self.lateMorphLimit) if self.split == 'Full': diff --git a/worlds/sm/variaRandomizer/randomizer.py b/worlds/sm/variaRandomizer/randomizer.py index 22712aa442..c3752c427a 100644 --- a/worlds/sm/variaRandomizer/randomizer.py +++ b/worlds/sm/variaRandomizer/randomizer.py @@ -1,7 +1,7 @@ #!/usr/bin/python3 from Utils import output_path -import argparse, os.path, json, sys, shutil, random, copy, requests +import argparse, os.path, json, sys, shutil, copy, requests from .rando.RandoSettings import RandoSettings, GraphSettings from .rando.RandoExec import RandoExec @@ -39,7 +39,7 @@ objectives = defaultMultiValues['objective'] tourians = defaultMultiValues['tourian'] areaRandomizations = defaultMultiValues['areaRandomization'] -def randomMulti(args, param, defaultMultiValues): +def randomMulti(args, param, defaultMultiValues, random): value = args[param] isRandom = False @@ -250,10 +250,11 @@ class VariaRandomizer: parser.add_argument('--tourianList', help="list to choose from when random", dest='tourianList', nargs='?', default=None) - def __init__(self, options, rom, player): + def __init__(self, options, rom, player, seed, random): # parse args self.args = copy.deepcopy(VariaRandomizer.parser.parse_args(["--logic", "varia"])) #dummy custom args to skip parsing _sys.argv while still get default values self.player = player + self.random = random args = self.args args.rom = rom # args.startLocation = to_pascal_case_with_space(options.startLocation.current_key) @@ -323,11 +324,13 @@ class VariaRandomizer: logger.debug("preset: {}".format(preset)) - # if no seed given, choose one - if args.seed == 0: - self.seed = random.randrange(sys.maxsize) - else: - self.seed = args.seed + # Archipelago provides a seed for the multiworld. + self.seed = seed + # # if no seed given, choose one + # if args.seed == 0: + # self.seed = random.randrange(sys.maxsize) + # else: + # self.seed = args.seed logger.debug("seed: {}".format(self.seed)) if args.raceMagic is not None: @@ -360,12 +363,12 @@ class VariaRandomizer: logger.debug("maxDifficulty: {}".format(self.maxDifficulty)) # handle random parameters with dynamic pool of values - (_, progSpeed) = randomMulti(args.__dict__, "progressionSpeed", speeds) - (_, progDiff) = randomMulti(args.__dict__, "progressionDifficulty", progDiffs) - (majorsSplitRandom, args.majorsSplit) = randomMulti(args.__dict__, "majorsSplit", majorsSplits) - (_, self.gravityBehaviour) = randomMulti(args.__dict__, "gravityBehaviour", gravityBehaviours) - (_, args.tourian) = randomMulti(args.__dict__, "tourian", tourians) - (areaRandom, args.area) = randomMulti(args.__dict__, "area", areaRandomizations) + (_, progSpeed) = randomMulti(args.__dict__, "progressionSpeed", speeds, random) + (_, progDiff) = randomMulti(args.__dict__, "progressionDifficulty", progDiffs, random) + (majorsSplitRandom, args.majorsSplit) = randomMulti(args.__dict__, "majorsSplit", majorsSplits, random) + (_, self.gravityBehaviour) = randomMulti(args.__dict__, "gravityBehaviour", gravityBehaviours, random) + (_, args.tourian) = randomMulti(args.__dict__, "tourian", tourians, random) + (areaRandom, args.area) = randomMulti(args.__dict__, "area", areaRandomizations, random) areaRandomization = args.area in ['light', 'full'] lightArea = args.area == 'light' @@ -626,7 +629,7 @@ class VariaRandomizer: if args.objective: if (args.objectiveRandom): availableObjectives = [goal for goal in objectives if goal != "collect 100% items"] if "random" in args.objectiveList else args.objectiveList - self.objectivesManager.setRandom(args.nbObjective, availableObjectives) + self.objectivesManager.setRandom(args.nbObjective, availableObjectives, self.random) else: maxActiveGoals = Objectives.maxActiveGoals - addedObjectives if len(args.objective) > maxActiveGoals: @@ -660,7 +663,7 @@ class VariaRandomizer: # print("energyQty:{}".format(energyQty)) #try: - self.randoExec = RandoExec(seedName, args.vcr, randoSettings, graphSettings, self.player) + self.randoExec = RandoExec(seedName, args.vcr, randoSettings, graphSettings, self.player, self.random) self.container = self.randoExec.randomize() # if we couldn't find an area layout then the escape graph is not created either # and getDoorConnections will crash if random escape is activated. @@ -690,7 +693,7 @@ class VariaRandomizer: 'gameend.ips', 'grey_door_animals.ips', 'low_timer.ips', 'metalimals.ips', 'phantoonimals.ips', 'ridleyimals.ips'] if args.escapeRando == False: - args.patches.append(random.choice(animalsPatches)) + args.patches.append(self.random.choice(animalsPatches)) args.patches.append("Escape_Animals_Change_Event") else: optErrMsgs.append("Ignored animals surprise because of escape randomization") @@ -760,9 +763,9 @@ class VariaRandomizer: # patch local rom # romFileName = args.rom # shutil.copyfile(romFileName, outputFilename) - romPatcher = RomPatcher(settings=patcherSettings, magic=args.raceMagic, player=self.player) + romPatcher = RomPatcher(settings=patcherSettings, magic=args.raceMagic, player=self.player, random=self.random) else: - romPatcher = RomPatcher(settings=patcherSettings, magic=args.raceMagic) + romPatcher = RomPatcher(settings=patcherSettings, magic=args.raceMagic, random=self.random) if customPrePatchApply != None: customPrePatchApply(romPatcher) diff --git a/worlds/sm/variaRandomizer/rom/rompatcher.py b/worlds/sm/variaRandomizer/rom/rompatcher.py index a350764a9c..7e5aa235fb 100644 --- a/worlds/sm/variaRandomizer/rom/rompatcher.py +++ b/worlds/sm/variaRandomizer/rom/rompatcher.py @@ -49,7 +49,7 @@ class RomPatcher: 'DoorsColors': ['beam_doors_plms.ips', 'beam_doors_gfx.ips', 'red_doors.ips'] } - def __init__(self, settings=None, romFileName=None, magic=None, player=0): + def __init__(self, settings=None, romFileName=None, magic=None, player=0, *, random): self.log = log.get('RomPatcher') self.settings = settings #self.romFileName = romFileName @@ -76,6 +76,7 @@ class RomPatcher: 0x93ea: self.forceRoomCRE } self.player = player + self.random = random def patchRom(self): self.applyIPSPatches() @@ -496,9 +497,9 @@ class RomPatcher: self.ipsPatches = [] def writeSeed(self, seed): - random.seed(seed) - seedInfo = random.randint(0, 0xFFFF) - seedInfo2 = random.randint(0, 0xFFFF) + r = random.Random(seed) + seedInfo = r.randint(0, 0xFFFF) + seedInfo2 = r.randint(0, 0xFFFF) self.romFile.writeWord(seedInfo, snes_to_pc(0xdfff00)) self.romFile.writeWord(seedInfo2) @@ -1066,7 +1067,7 @@ class RomPatcher: def writeObjectives(self, itemLocs, tourian): objectives = Objectives.objDict[self.player] - objectives.writeGoals(self.romFile) + objectives.writeGoals(self.romFile, self.random) objectives.writeIntroObjectives(self.romFile, tourian) self.writeItemsMasks(itemLocs) # hack bomb_torizo.ips to wake BT in all cases if necessary, ie chozo bots objective is on, and nothing at bombs diff --git a/worlds/sm/variaRandomizer/utils/doorsmanager.py b/worlds/sm/variaRandomizer/utils/doorsmanager.py index 6a8ecda1cb..425958c549 100644 --- a/worlds/sm/variaRandomizer/utils/doorsmanager.py +++ b/worlds/sm/variaRandomizer/utils/doorsmanager.py @@ -1,4 +1,3 @@ -import random from enum import IntEnum,IntFlag import copy from ..logic.smbool import SMBool @@ -123,7 +122,7 @@ class Door(object): else: return [color for color in colorsList if color not in self.forbiddenColors] - def randomize(self, allowGreyDoors): + def randomize(self, allowGreyDoors, random): if self.canRandomize(): if self.canGrey and allowGreyDoors: self.setColor(random.choice(self.filterColorList(colorsListGrey))) @@ -347,9 +346,9 @@ class DoorsManager(): currentDoors['CrabShaftRight'].forceBlue() @staticmethod - def randomize(allowGreyDoors, player): + def randomize(allowGreyDoors, player, random): for door in DoorsManager.doorsDict[player].values(): - door.randomize(allowGreyDoors) + door.randomize(allowGreyDoors, random) # set both ends of toilet to the same color to avoid soft locking in area rando toiletTop = DoorsManager.doorsDict[player]['PlasmaSparkBottom'] toiletBottom = DoorsManager.doorsDict[player]['OasisTop'] diff --git a/worlds/sm/variaRandomizer/utils/objectives.py b/worlds/sm/variaRandomizer/utils/objectives.py index 67cdb9a1c1..8ca2ce6524 100644 --- a/worlds/sm/variaRandomizer/utils/objectives.py +++ b/worlds/sm/variaRandomizer/utils/objectives.py @@ -1,5 +1,4 @@ import copy -import random from ..rom.addresses import Addresses from ..rom.rom import pc_to_snes from ..logic.helpers import Bosses @@ -28,7 +27,7 @@ class Synonyms(object): ] alreadyUsed = [] @staticmethod - def getVerb(): + def getVerb(random): verb = random.choice(Synonyms.killSynonyms) while verb in Synonyms.alreadyUsed: verb = random.choice(Synonyms.killSynonyms) @@ -88,10 +87,10 @@ class Goal(object): # not all objectives require an ap (like limit objectives) return self.clearFunc(smbm, ap) - def getText(self): + def getText(self, random): out = "{}. ".format(self.rank) if self.useSynonym: - out += self.text.format(Synonyms.getVerb()) + out += self.text.format(Synonyms.getVerb(random)) else: out += self.text assert len(out) <= 28, "Goal text '{}' is too long: {}, max 28".format(out, len(out)) @@ -676,7 +675,7 @@ class Objectives(object): return [goal.name for goal in _goals.values() if goal.available and (not removeNothing or goal.name != "nothing")] # call from rando - def setRandom(self, nbGoals, availableGoals): + def setRandom(self, nbGoals, availableGoals, random): while self.nbActiveGoals < nbGoals and availableGoals: goalName = random.choice(availableGoals) self.addGoal(goalName) @@ -702,7 +701,7 @@ class Objectives(object): LOG.debug("tourianRequired: {}".format(self.tourianRequired)) # call from rando - def writeGoals(self, romFile): + def writeGoals(self, romFile, random): # write check functions romFile.seek(Addresses.getOne('objectivesList')) for goal in self.activeGoals: @@ -736,7 +735,7 @@ class Objectives(object): space = 3 if self.nbActiveGoals == 5 else 4 for i, goal in enumerate(self.activeGoals): addr = baseAddr + i * lineLength * space - text = goal.getText() + text = goal.getText(random) romFile.seek(addr) for c in text: if c not in char2tile: diff --git a/worlds/sm/variaRandomizer/utils/utils.py b/worlds/sm/variaRandomizer/utils/utils.py index f7d699b665..c297a3ccc7 100644 --- a/worlds/sm/variaRandomizer/utils/utils.py +++ b/worlds/sm/variaRandomizer/utils/utils.py @@ -1,5 +1,5 @@ import io -import os, json, re, random +import os, json, re import pathlib import sys from typing import Any @@ -88,7 +88,7 @@ def normalizeRounding(n): # gauss random in [0, r] range # the higher the slope, the less probable extreme values are. -def randGaussBounds(r, slope=5): +def randGaussBounds(random, r, slope=5): r = float(r) n = normalizeRounding(random.gauss(r/2, r/slope)) if n < 0: @@ -111,7 +111,7 @@ def getRangeDict(weightDict): return rangeDict -def chooseFromRange(rangeDict): +def chooseFromRange(rangeDict, random): r = random.random() val = None for v in sorted(rangeDict, key=rangeDict.get): From bb6c753583ddd0f04edcbf11908a8d81ed6042a7 Mon Sep 17 00:00:00 2001 From: Fabian Dill Date: Tue, 1 Apr 2025 18:19:07 +0200 Subject: [PATCH 19/53] FFMQ: fix remote code execution (#4786) --- worlds/ffmq/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/worlds/ffmq/__init__.py b/worlds/ffmq/__init__.py index 58dc4bf13e..e80a4cf998 100644 --- a/worlds/ffmq/__init__.py +++ b/worlds/ffmq/__init__.py @@ -3,7 +3,6 @@ import settings import base64 import threading import requests -import yaml from worlds.AutoWorld import World, WebWorld from BaseClasses import Tutorial from .Regions import create_regions, location_table, set_rules, stage_set_rules, rooms, non_dead_end_crest_rooms,\ @@ -134,7 +133,7 @@ class FFMQWorld(World): errors.append([api_url, err]) else: if response.ok: - world.rooms = rooms_data[query] = yaml.load(response.text, yaml.Loader) + world.rooms = rooms_data[query] = Utils.parse_yaml(response.text) break else: api_urls.remove(api_url) From d26db6f2134fe06d3fba3e2b398bccc9c59f6907 Mon Sep 17 00:00:00 2001 From: LiquidCat64 <74896918+LiquidCat64@users.noreply.github.com> Date: Tue, 1 Apr 2025 10:37:49 -0600 Subject: [PATCH 20/53] CV64: Fix some unrandomized locations containing unintended items on specific settings (#4728) * Fix some unrandomized locations on specific settings. * Remove now-unnecessary comment --- worlds/cv64/rom.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/worlds/cv64/rom.py b/worlds/cv64/rom.py index 7af4e3807a..1833c7812b 100644 --- a/worlds/cv64/rom.py +++ b/worlds/cv64/rom.py @@ -644,6 +644,9 @@ class CV64PatchExtensions(APPatchExtension): # Replace the PowerUp in the Forest Special1 Bridge 3HB rock with an L jewel if 3HBs aren't randomized if not options["multi_hit_breakables"]: rom_data.write_byte(0x10C7A1, 0x03) + # Replace the PowerUp in one of the lizard lockers if the lizard locker items aren't randomized. + if not options["lizard_locker_items"]: + rom_data.write_byte(0xBFCA07, 0x03) # Change the appearance of the Pot-Pourri to that of a larger PowerUp regardless of the above setting, so other # game PermaUps are distinguishable. rom_data.write_int32s(0xEE558, [0x06005F08, 0x3FB00000, 0xFFFFFF00]) @@ -714,7 +717,11 @@ class CV64PatchExtensions(APPatchExtension): rom_data.write_int32(0x10CF38, 0x8000FF4D) # CT final room door slab rom_data.write_int32(0x10CF44, 0x1000FF4D) # CT Renon slab rom_data.write_int32(0x10C908, 0x0008FF4D) # Villa foyer chandelier - rom_data.write_byte(0x10CF37, 0x04) # pointer for CT final room door slab item data + + # Change the pointer to the Clock Tower final room 3HB door slab drops to not share its values with those of the + # 3HB slab near Renon at the top of the room. + if options["multi_hit_breakables"]: + rom_data.write_byte(0x10CF37, 0x04) # Once-per-frame gameplay checks rom_data.write_int32(0x6C848, 0x080FF40D) # J 0x803FD034 @@ -1000,6 +1007,7 @@ def write_patch(world: "CV64World", patch: CV64ProcedurePatch, offset_data: Dict "multi_hit_breakables": world.options.multi_hit_breakables.value, "drop_previous_sub_weapon": world.options.drop_previous_sub_weapon.value, "countdown": world.options.countdown.value, + "lizard_locker_items": world.options.lizard_locker_items.value, "shopsanity": world.options.shopsanity.value, "panther_dash": world.options.panther_dash.value, "big_toss": world.options.big_toss.value, From ff8e1dfb4794b2ea7f7645724c3259dcdadb287d Mon Sep 17 00:00:00 2001 From: threeandthreee Date: Tue, 1 Apr 2025 15:28:59 -0400 Subject: [PATCH 21/53] Launcher: Remove an unnecessary global (#4785) --- worlds/LauncherComponents.py | 1 - 1 file changed, 1 deletion(-) diff --git a/worlds/LauncherComponents.py b/worlds/LauncherComponents.py index 41c83db419..2c9337388a 100644 --- a/worlds/LauncherComponents.py +++ b/worlds/LauncherComponents.py @@ -88,7 +88,6 @@ processes = weakref.WeakSet() def launch_subprocess(func: Callable, name: str | None = None, args: Tuple[str, ...] = ()) -> None: - global processes import multiprocessing process = multiprocessing.Process(target=func, name=name, args=args) process.start() From 96be0071e64be2edd58eeb8a5914a1c9ee475fa6 Mon Sep 17 00:00:00 2001 From: Bryce Wilson Date: Tue, 1 Apr 2025 15:50:39 -0700 Subject: [PATCH 22/53] Pokemon Emerald: Move recent change to new version (#4793) --- worlds/pokemon_emerald/CHANGELOG.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/worlds/pokemon_emerald/CHANGELOG.md b/worlds/pokemon_emerald/CHANGELOG.md index 2a0130b128..3921e33400 100644 --- a/worlds/pokemon_emerald/CHANGELOG.md +++ b/worlds/pokemon_emerald/CHANGELOG.md @@ -1,3 +1,11 @@ +# 2.4.1 + +### Fixes + +- Fixed handling of shuffle option for badges/HMs in the case that the player sets those items to nonlocal or uses +plando to put an item in one of those locations, or in the case that fill gets itself stuck on these items and has to +retry. + # 2.4.0 ### Features @@ -14,9 +22,6 @@ _not_ used for logical access (the seed will never require you to catch somethin - Now excludes the location "Navel Rock Top - Hidden Item Sacred Ash" if your goal is Champion and you didn't randomize event tickets. -- Fixed handling of shuffle option for badges/HMs in the case that the player sets those items to nonlocal or uses -plando to put an item in one of those locations, or in the case that fill gets itself stuck on these items and has to -retry. # 2.3.0 From daee6d210f1b8a356fd2408c83e25d4aac54fc26 Mon Sep 17 00:00:00 2001 From: Sanjay Govind Date: Wed, 2 Apr 2025 12:54:27 +1300 Subject: [PATCH 23/53] CommonClient: don't update ui hints if there is no ui (#4791) --- CommonClient.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CommonClient.py b/CommonClient.py index ae411838d8..bf4b70ebe5 100644 --- a/CommonClient.py +++ b/CommonClient.py @@ -413,7 +413,8 @@ class CommonContext: await self.server.socket.close() if self.server_task is not None: await self.server_task - self.ui.update_hints() + if self.ui: + self.ui.update_hints() async def send_msgs(self, msgs: typing.List[typing.Any]) -> None: """ `msgs` JSON serializable """ From 364a1b71ec2269d32a630a5c962cee01c4755318 Mon Sep 17 00:00:00 2001 From: Scipio Wright Date: Tue, 1 Apr 2025 19:55:19 -0400 Subject: [PATCH 24/53] TUNIC: Note Death Link and Trap Link in-game toggles on Game Info page (#4741) * Note death link and trap link in game info page * Update worlds/tunic/docs/en_TUNIC.md Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> * Turn it into a bulleted list --- worlds/tunic/docs/en_TUNIC.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/worlds/tunic/docs/en_TUNIC.md b/worlds/tunic/docs/en_TUNIC.md index ab751d8e66..610c7edf48 100644 --- a/worlds/tunic/docs/en_TUNIC.md +++ b/worlds/tunic/docs/en_TUNIC.md @@ -88,6 +88,8 @@ Notes: See the [Archipelago Plando Guide](../../../tutorial/Archipelago/plando/en) for more information on Plando and Connection Plando. ## Is there anything else I should know? -You can go to [The TUNIC Randomizer Website](https://rando.tunic.run/) for a list of randomizer features as well as some helpful tips. -You can use the Fairy Seeking Spell (ULU RDR) to locate the nearest unchecked location. -You can use the Entrance Seeking Spell (RDR ULU) to locate the nearest unused entrance. +- You can go to [The TUNIC Randomizer Website](https://rando.tunic.run/) for a list of randomizer features as well as some helpful tips. +- You can use the Fairy Seeking Spell (ULU RDR) to locate the nearest unchecked location. +- You can use the Entrance Seeking Spell (RDR ULU) to locate the nearest unused entrance. +- Death Link can be toggled in game, and it can be set to receive traps instead of deaths. +- Trap Link can be toggled in-game as well, which makes it so other players with Trap Link enabled will share the effects of traps with you, and vice versa. Trap Link functions cross-game, but only with other games that have Trap Link implemented, and only some traps can be shared, depending on the game. From d07f36dedd93b22056fe24b04d3cf06915da1803 Mon Sep 17 00:00:00 2001 From: Fabian Dill Date: Wed, 2 Apr 2025 05:35:39 +0200 Subject: [PATCH 25/53] Core: increment version (#4787) --- Utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Utils.py b/Utils.py index c7f13f144d..8b5bc01315 100644 --- a/Utils.py +++ b/Utils.py @@ -47,7 +47,7 @@ class Version(typing.NamedTuple): return ".".join(str(item) for item in self) -__version__ = "0.6.0" +__version__ = "0.6.1" version_tuple = tuplize_version(__version__) is_linux = sys.platform.startswith("linux") From 7265468e8d701fbebfc5fd0d5622c7ce2e5d81bb Mon Sep 17 00:00:00 2001 From: Fabian Dill Date: Thu, 3 Apr 2025 09:22:02 +0200 Subject: [PATCH 26/53] kvui: fix [u] and [/u] appearing in copied hints (#4794) --- kvui.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kvui.py b/kvui.py index 3616207298..1c2bd74530 100644 --- a/kvui.py +++ b/kvui.py @@ -296,7 +296,7 @@ class SelectableLabel(RecycleDataViewBehavior, TooltipLabel): else: # Not a fan of the following few lines, but they work. temp = MarkupLabel(text=self.text).markup - text = "".join(part for part in temp if not part.startswith(("[color", "[/color]", "[ref=", "[/ref]"))) + text = "".join(part for part in temp if not part.startswith("[")) cmdinput = App.get_running_app().textinput if not cmdinput.text: input_text = get_input_text_from_response(text, App.get_running_app().last_autofillable_command) From d8576e72eb609cb9d779fe59f4243e3dd8afb9aa Mon Sep 17 00:00:00 2001 From: CodeGorilla Date: Fri, 4 Apr 2025 03:48:47 -0500 Subject: [PATCH 27/53] =?UTF-8?q?Pokemon=20Red/Blue:=20Set=20allow=5Fparti?= =?UTF-8?q?al=5Fentrances=20to=20true=20when=20building=20a=20state=20for?= =?UTF-8?q?=20ER=C2=A0#4802?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: CodeGorilla <3672561+Ars-Ignis@users.noreply.github.com> --- worlds/pokemon_rb/regions.py | 1 + 1 file changed, 1 insertion(+) diff --git a/worlds/pokemon_rb/regions.py b/worlds/pokemon_rb/regions.py index aef0b945a2..a7c0b6d533 100644 --- a/worlds/pokemon_rb/regions.py +++ b/worlds/pokemon_rb/regions.py @@ -2414,6 +2414,7 @@ def door_shuffle(world, multiworld, player, badges, badge_locs): loc.place_locked_item(badge) state = multiworld.state.copy() + state.allow_partial_entrances = True for item, data in item_table.items(): if (data.id or item in poke_data.pokemon_data) and data.classification == ItemClassification.progression \ and ("Badge" not in item or world.options.badgesanity): From 1bec68df4dd62905b591f63493bb4c26ab46c6c0 Mon Sep 17 00:00:00 2001 From: massimilianodelliubaldini <8584296+massimilianodelliubaldini@users.noreply.github.com> Date: Fri, 4 Apr 2025 17:11:45 -0400 Subject: [PATCH 28/53] WebHost: Standardize some 404 redirects (#4642) --- WebHostLib/misc.py | 12 ++++++++++++ WebHostLib/options.py | 12 +++++++++--- WebHostLib/static/assets/gameInfo.js | 5 ----- WebHostLib/static/assets/tutorial.js | 5 ----- worlds/alttp/__init__.py | 1 + worlds/aquaria/__init__.py | 1 + worlds/clique/__init__.py | 1 + worlds/dlcquest/__init__.py | 1 + worlds/ffmq/__init__.py | 1 + worlds/oot/__init__.py | 1 + worlds/sc2/__init__.py | 1 + 11 files changed, 28 insertions(+), 13 deletions(-) diff --git a/WebHostLib/misc.py b/WebHostLib/misc.py index 6be0e470b3..98731b65bd 100644 --- a/WebHostLib/misc.py +++ b/WebHostLib/misc.py @@ -35,6 +35,12 @@ def start_playing(): @app.route('/games//info/') @cache.cached() def game_info(game, lang): + try: + world = AutoWorldRegister.world_types[game] + if lang not in world.web.game_info_languages: + raise KeyError("Sorry, this game's info page is not available in that language yet.") + except KeyError: + return abort(404) return render_template('gameInfo.html', game=game, lang=lang, theme=get_world_theme(game)) @@ -52,6 +58,12 @@ def games(): @app.route('/tutorial///') @cache.cached() def tutorial(game, file, lang): + try: + world = AutoWorldRegister.world_types[game] + if lang not in [tut.link.split("/")[1] for tut in world.web.tutorials]: + raise KeyError("Sorry, the tutorial is not available in that language yet.") + except KeyError: + return abort(404) return render_template("tutorial.html", game=game, file=file, lang=lang, theme=get_world_theme(game)) diff --git a/WebHostLib/options.py b/WebHostLib/options.py index 15b7bd61ce..711762ee5f 100644 --- a/WebHostLib/options.py +++ b/WebHostLib/options.py @@ -6,7 +6,7 @@ from typing import Dict, Union from docutils.core import publish_parts import yaml -from flask import redirect, render_template, request, Response +from flask import redirect, render_template, request, Response, abort import Options from Utils import local_path @@ -142,7 +142,10 @@ def weighted_options_old(): @app.route("/games//weighted-options") @cache.cached() def weighted_options(game: str): - return render_options_page("weightedOptions/weightedOptions.html", game, is_complex=True) + try: + return render_options_page("weightedOptions/weightedOptions.html", game, is_complex=True) + except KeyError: + return abort(404) @app.route("/games//generate-weighted-yaml", methods=["POST"]) @@ -197,7 +200,10 @@ def generate_weighted_yaml(game: str): @app.route("/games//player-options") @cache.cached() def player_options(game: str): - return render_options_page("playerOptions/playerOptions.html", game, is_complex=False) + try: + return render_options_page("playerOptions/playerOptions.html", game, is_complex=False) + except KeyError: + return abort(404) # YAML generator for player-options diff --git a/WebHostLib/static/assets/gameInfo.js b/WebHostLib/static/assets/gameInfo.js index b8c56905a5..1d6d136135 100644 --- a/WebHostLib/static/assets/gameInfo.js +++ b/WebHostLib/static/assets/gameInfo.js @@ -42,10 +42,5 @@ window.addEventListener('load', () => { scrollTarget?.scrollIntoView(); } }); - }).catch((error) => { - console.error(error); - gameInfo.innerHTML = - `

This page is out of logic!

-

Click here to return to safety.

`; }); }); diff --git a/WebHostLib/static/assets/tutorial.js b/WebHostLib/static/assets/tutorial.js index 1db08d85b3..d527966005 100644 --- a/WebHostLib/static/assets/tutorial.js +++ b/WebHostLib/static/assets/tutorial.js @@ -49,10 +49,5 @@ window.addEventListener('load', () => { scrollTarget?.scrollIntoView(); } }); - }).catch((error) => { - console.error(error); - tutorialWrapper.innerHTML = - `

This page is out of logic!

-

Click here to return to safety.

`; }); }); diff --git a/worlds/alttp/__init__.py b/worlds/alttp/__init__.py index b548990688..e4a04fe67e 100644 --- a/worlds/alttp/__init__.py +++ b/worlds/alttp/__init__.py @@ -121,6 +121,7 @@ class ALTTPWeb(WebWorld): ) tutorials = [setup_en, setup_de, setup_es, setup_fr, msu, msu_es, msu_fr, plando, oof_sound] + game_info_languages = ["en", "fr"] class ALTTPWorld(World): diff --git a/worlds/aquaria/__init__.py b/worlds/aquaria/__init__.py index 54158b280b..084d31444f 100644 --- a/worlds/aquaria/__init__.py +++ b/worlds/aquaria/__init__.py @@ -41,6 +41,7 @@ class AquariaWeb(WebWorld): ) tutorials = [setup, setup_fr] + game_info_languages = ["en", "fr"] class AquariaWorld(World): diff --git a/worlds/clique/__init__.py b/worlds/clique/__init__.py index 3d06e477eb..70777c51b0 100644 --- a/worlds/clique/__init__.py +++ b/worlds/clique/__init__.py @@ -31,6 +31,7 @@ class CliqueWebWorld(WebWorld): ) tutorials = [setup_en, setup_de] + game_info_languages = ["en", "de"] class CliqueWorld(World): diff --git a/worlds/dlcquest/__init__.py b/worlds/dlcquest/__init__.py index 37eae9b447..8c495ef698 100644 --- a/worlds/dlcquest/__init__.py +++ b/worlds/dlcquest/__init__.py @@ -34,6 +34,7 @@ class DLCqwebworld(WebWorld): ["Deoxis"] ) tutorials = [setup_en, setup_fr] + game_info_languages = ["en", "fr"] class DLCqworld(World): diff --git a/worlds/ffmq/__init__.py b/worlds/ffmq/__init__.py index e80a4cf998..c749909a1d 100644 --- a/worlds/ffmq/__init__.py +++ b/worlds/ffmq/__init__.py @@ -43,6 +43,7 @@ class FFMQWebWorld(WebWorld): ) tutorials = [setup_en, setup_fr] + game_info_languages = ["en", "fr"] class FFMQWorld(World): diff --git a/worlds/oot/__init__.py b/worlds/oot/__init__.py index 1742ab72dd..136439ee96 100644 --- a/worlds/oot/__init__.py +++ b/worlds/oot/__init__.py @@ -130,6 +130,7 @@ class OOTWeb(WebWorld): tutorials = [setup, setup_fr, setup_de] option_groups = oot_option_groups + game_info_languages = ["en", "de"] class OOTWorld(World): diff --git a/worlds/sc2/__init__.py b/worlds/sc2/__init__.py index ec8a447d93..f11059a54e 100644 --- a/worlds/sc2/__init__.py +++ b/worlds/sc2/__init__.py @@ -41,6 +41,7 @@ class Starcraft2WebWorld(WebWorld): ) tutorials = [setup_en, setup_fr] + game_info_languages = ["en", "fr"] class SC2World(World): From f42233699ab7cdf947898b470101201164736c62 Mon Sep 17 00:00:00 2001 From: qwint Date: Fri, 4 Apr 2025 16:20:45 -0500 Subject: [PATCH 29/53] Core: make accessibility_corrections only state.remove if the location was collected --- Fill.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Fill.py b/Fill.py index d1773c8213..efc8230fa1 100644 --- a/Fill.py +++ b/Fill.py @@ -348,10 +348,10 @@ def accessibility_corrections(multiworld: MultiWorld, state: CollectionState, lo if (location.item is not None and location.item.advancement and location.address is not None and not location.locked and location.item.player not in minimal_players): pool.append(location.item) - state.remove(location.item) location.item = None if location in state.advancements: state.advancements.remove(location) + state.remove(location.item) locations.append(location) if pool and locations: locations.sort(key=lambda loc: loc.progress_type != LocationProgressType.PRIORITY) From 215eb7e473ac6ccd5d651ea4ae0724a6dbe4ac4c Mon Sep 17 00:00:00 2001 From: Fabian Dill Date: Fri, 4 Apr 2025 23:25:37 +0200 Subject: [PATCH 30/53] core: increment version (#4808) --- Utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Utils.py b/Utils.py index 8b5bc01315..202b8da178 100644 --- a/Utils.py +++ b/Utils.py @@ -47,7 +47,7 @@ class Version(typing.NamedTuple): return ".".join(str(item) for item in self) -__version__ = "0.6.1" +__version__ = "0.6.2" version_tuple = tuplize_version(__version__) is_linux = sys.platform.startswith("linux") From b5bf9ed1d785be57f6052e0c24a2abc42e92956f Mon Sep 17 00:00:00 2001 From: Scipio Wright Date: Fri, 4 Apr 2025 18:53:13 -0400 Subject: [PATCH 31/53] TUNIC: Error message in the spot that UT errors at if you have an old APWorld #4788 Schnice and Shrimple --- worlds/tunic/er_scripts.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/worlds/tunic/er_scripts.py b/worlds/tunic/er_scripts.py index ddb4ec6c58..597c65b920 100644 --- a/worlds/tunic/er_scripts.py +++ b/worlds/tunic/er_scripts.py @@ -330,7 +330,11 @@ def pair_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Portal else: if not portal2: raise Exception(f"Could not find entrance named {p_exit} for " - f"plando connections in {player_name}'s YAML.") + f"plando connections in {player_name}'s YAML.\n" + f"If you are using Universal Tracker, the most likely reason for this error " + f"is that the host generated with a newer version of the APWorld.\n" + f"Please check the TUNIC Randomizer Github and place the newest APWorld in your " + f"custom_worlds folder, and remove the one in lib/worlds if there is one there.") dead_ends.remove(portal2) # update the traversal chart to say you can get from portal1's region to portal2's and vice versa From 507e051a5a95f78f6cd417ae31190289df43bb3c Mon Sep 17 00:00:00 2001 From: Richard Snider Date: Fri, 4 Apr 2025 19:36:20 -0600 Subject: [PATCH 32/53] Core: Handle integer arguments in player names gracefully (#4151) --- Generate.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/Generate.py b/Generate.py index b057db25a3..4816155043 100644 --- a/Generate.py +++ b/Generate.py @@ -279,22 +279,30 @@ def get_choice(option, root, value=None) -> Any: raise RuntimeError(f"All options specified in \"{option}\" are weighted as zero.") -class SafeDict(dict): - def __missing__(self, key): - return '{' + key + '}' +class SafeFormatter(string.Formatter): + def get_value(self, key, args, kwargs): + if isinstance(key, int): + if key < len(args): + return args[key] + else: + return "{" + str(key) + "}" + else: + return kwargs.get(key, "{" + key + "}") def handle_name(name: str, player: int, name_counter: Counter): name_counter[name.lower()] += 1 number = name_counter[name.lower()] new_name = "%".join([x.replace("%number%", "{number}").replace("%player%", "{player}") for x in name.split("%%")]) - new_name = string.Formatter().vformat(new_name, (), SafeDict(number=number, - NUMBER=(number if number > 1 else ''), - player=player, - PLAYER=(player if player > 1 else ''))) + + new_name = SafeFormatter().vformat(new_name, (), {"number": number, + "NUMBER": (number if number > 1 else ''), + "player": player, + "PLAYER": (player if player > 1 else '')}) # Run .strip twice for edge case where after the initial .slice new_name has a leading whitespace. # Could cause issues for some clients that cannot handle the additional whitespace. new_name = new_name.strip()[:16].strip() + if new_name == "Archipelago": raise Exception(f"You cannot name yourself \"{new_name}\"") return new_name From 2c90db9ae7f362a7e97b430658f1720056d6ee6e Mon Sep 17 00:00:00 2001 From: massimilianodelliubaldini <8584296+massimilianodelliubaldini@users.noreply.github.com> Date: Fri, 4 Apr 2025 22:18:47 -0400 Subject: [PATCH 33/53] Docs: Additional detail and organization to adding games.md (#4805) * Additional detail and organization to adding games.md * Minor fixes. * Update docs/adding games.md Co-authored-by: qwint * Code review updates. * More updates. * Client icon blurb. * Update docs/adding games.md Co-authored-by: qwint * Revert one line. * Filler item name blurb. * Updates for Violet. * Reorganize client expectations. * Missed a line delete. * Doctor's orders --------- Co-authored-by: qwint --- docs/adding games.md | 167 +++++++++++++++++++++++++++++++------------ 1 file changed, 123 insertions(+), 44 deletions(-) diff --git a/docs/adding games.md b/docs/adding games.md index 9d2860b4a1..fbbd798859 100644 --- a/docs/adding games.md +++ b/docs/adding games.md @@ -1,5 +1,8 @@ # Adding Games +Like all contributions to Archipelago, New Game implementations should follow the [Contributing](/docs/contributing.md) +guide. + Adding a new game to Archipelago has two major parts: * Game Modification to communicate with Archipelago server (hereafter referred to as "client") @@ -13,30 +16,51 @@ it will not be detailed here. The client is an intermediary program between the game and the Archipelago server. This can either be a direct modification to the game, an external program, or both. This can be implemented in nearly any modern language, but it -must fulfill a few requirements in order to function as expected. The specific requirements the game client must follow -to behave as expected are: +must fulfill a few requirements in order to function as expected. Libraries for most modern languages and the spec for +various packets can be found in the [network protocol](/docs/network%20protocol.md) API reference document. + +### Hard Requirements + +In order for the game client to behave as expected, it must be able to perform these functions: * Handle both secure and unsecure websocket connections -* Detect and react when a location has been "checked" by the player by sending a network packet to the server -* Receive and parse network packets when the player receives an item from the server, and reward it to the player on -demand - * **Any** of your items can be received any number of times, up to and far surpassing those that the game might -normally expect from features such as starting inventory, item link replacement, or item cheating - * Players and the admin can cheat items to the player at any time with a server command, and these items may not have -a player or location attributed to them +* Reconnect if the connection is unstable and lost while playing * Be able to change the port for saved connection info * Rooms hosted on the website attempt to reserve their port, but since there are a limited number of ports, this -privilege can be lost, requiring the room to be moved to a new port -* Reconnect if the connection is unstable and lost while playing -* Keep an index for items received in order to resync. The ItemsReceived Packets are a single list with guaranteed -order. -* Receive items that were sent to the player while they were not connected to the server - * The player being able to complete checks while offline and sending them when reconnecting is a good bonus, but not -strictly required + privilege can be lost, requiring the room to be moved to a new port * Send a status update packet alerting the server that the player has completed their goal -Libraries for most modern languages and the spec for various packets can be found in the -[network protocol](/docs/network%20protocol.md) API reference document. +Regarding items and locations, the game client must be able to handle these tasks: + +#### Location Handling + +Send a network packet to the server when it detects a location has been "checked" by the player in-game. + +* If actions were taken in game that would usually trigger a location check, and those actions can only ever be taken + once, but the client was not connected when they happened: The client must send those location checks on connection + so that they are not permanently lost, e.g. by reading flags in the game state or save file. + +#### Item Handling + +Receive and parse network packets from the server when the player receives an item. + +* It must reward items to the player on demand, as items can come from other players at any time. +* It must be able to reward copies of an item, up to and beyond the number the game normally expects. This may happen + due to features such as starting inventory, item link replacement, admin commands, or item cheating. **Any** of + your items can be received **any** number of times. +* Admins and players may use server commands to create items without a player or location attributed to them. The + client must be able to handle these items. +* It must keep an index for items received in order to resync. The ItemsReceived Packets are a single list with a + guaranteed order. +* It must be able to receive items that were sent to the player while they were not connected to the server. + +### Encouraged Features + +These are "nice to have" features for a client, but they are not strictly required. It is encouraged to add them +if possible. + +* If your client appears in the Archipelago Launcher, you may define an icon for it that differentiates it from + other clients. The icon size is 38x38 pixels, but it will accept larger images with downscaling. ## World @@ -44,35 +68,90 @@ The world is your game integration for the Archipelago generator, webhost, and m information necessary for creating the items and locations to be randomized, the logic for item placement, the datapackage information so other game clients can recognize your game data, and documentation. Your world must be written as a Python package to be loaded by Archipelago. This is currently done by creating a fork of the Archipelago -repository and creating a new world package in `/worlds/`. A bare minimum world implementation must satisfy the -following requirements: +repository and creating a new world package in `/worlds/`. -* A folder within `/worlds/` that contains an `__init__.py` -* A `World` subclass where you create your world and define all of its rules -* A unique game name -* For webhost documentation and behaviors, a `WebWorld` subclass that must be instantiated in the `World` class -definition - * The game_info doc must follow the format `{language_code}_{game_name}.md` +The base World class can be found in [AutoWorld](/worlds/AutoWorld.py). Methods available for your world to call +during generation can be found in [BaseClasses](/BaseClasses.py) and [Fill](/Fill.py). Some examples and documentation +regarding the API can be found in the [world api doc](/docs/world%20api.md). Before publishing, make sure to also +check out [world maintainer.md](/docs/world%20maintainer.md). + +### Hard Requirements + +A bare minimum world implementation must satisfy the following requirements: + +* It has a folder with the name of your game (or an abbreviation) under `/worlds/` +* The `/worlds/{game}` folder contains an `__init__.py` +* Any subfolders within `/worlds/{game}` that contain `*.py` files also contain an `__init__.py` for frozen build + packaging +* The game folder has at least one game_info doc named with follow the format `{language_code}_{game_name}.md` +* The game folder has at least one setup doc +* There must be a `World` subclass in your game folder (typically in `/worlds/{game}/__init__.py`) where you create + your world and define all of its rules and features + +Within the `World` subclass you should also have: + +* A [unique game name](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L260) +* An [instance](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L295) of a `WebWorld` +subclass for webhost documentation and behaviors + * In your `WebWorld`, if you wrote a game_info doc in more than one language, override the list of + [game info languages](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L210) with the + ones you include. + * In your `WebWorld`, override the list of + [tutorials](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L213) with each tutorial + or setup doc you included in the game folder. * A mapping for items and locations defining their names and ids for clients to be able to identify them. These are -`item_name_to_id` and `location_name_to_id`, respectively. -* Create an item when `create_item` is called both by your code and externally -* An `options_dataclass` defining the options players have available to them -* A `Region` for your player with the name "Menu" to start from -* Create a non-zero number of locations and add them to your regions -* Create a non-zero number of items **equal** to the number of locations and add them to the multiworld itempool -* All items submitted to the multiworld itempool must not be manually placed by the World. If you need to place specific -items, there are multiple ways to do so, but they should not be added to the multiworld itempool. + `item_name_to_id` and `location_name_to_id`, respectively. +* An implementation of `create_item` that can create an item when called by either your code or by another process + within Archipelago +* At least one `Region` for your player to start from (i.e. the Origin Region) + * The default name of this region is "Menu" but you may configure a different name with + [origin_region_name](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L298-L299) +* A non-zero number of locations, added to your regions +* A non-zero number of items **equal** to the number of locations, added to the multiworld itempool + * In rare cases, there may be 0-location-0-item games, but this is extremely atypical. -Notable caveats: -* The "Menu" region will always be considered the "start" for the player -* The "Menu" region is *always* considered accessible; i.e. the player is expected to always be able to return to the +### Encouraged Features + +These are "nice to have" features for a world, but they are not strictly required. It is encouraged to add them +if possible. + +* An implementation of + [get_filler_item_name](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L473) + * By default, this function chooses any item name from `item_name_to_id`, so you want to limit it to only the true + filler items. +* An `options_dataclass` defining the options players have available to them + * This should be accompanied by a type hint for `options` with the same class name +* A [bug report page](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L220) +* A list of [option groups](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L226) + for better organization on the webhost +* A dictionary of [options presets](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L223) + for player convenience +* A dictionary of [item name groups](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L273) + for player convenience +* A dictionary of + [location name groups](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L276) + for player convenience + * Other games may also benefit from your name group dictionaries for hints, features, etc. + +### Discouraged or Prohibited Behavior + +These are behaviors or implementations that are known to cause various issues. Some of these points have notable +workarounds or preferred methods which should be used instead: + +* All items submitted to the multiworld itempool must not be manually placed by the World. + * If you need to place specific items, there are multiple ways to do so, but they should not be added to the + multiworld itempool. +* It is not allowed to use `eval` for most reasons, chiefly due to security concerns. +* It is discouraged to use `yaml.load` directly due to security concerns. + * When possible, use `Utils.yaml_load` instead, as this defaults to the safe loader. +* When submitting regions or items to the multiworld (`multiworld.regions` and `multiworld.itempool` respectively), + Do **not** use `=` as this will overwrite all elements for all games in the seed. + * Instead, use `append`, `extend`, or `+=`. + +### Notable Caveats + +* The Origin Region will always be considered the "start" for the player +* The Origin Region is *always* considered accessible; i.e. the player is expected to always be able to return to the start of the game from anywhere -* When submitting regions or items to the multiworld (multiworld.regions and multiworld.itempool respectively), use -`append`, `extend`, or `+=`. **Do not use `=`** * Regions are simply containers for locations that share similar access rules. They do not have to map to concrete, physical areas within your game and can be more abstract like tech trees or a questline. - -The base World class can be found in [AutoWorld](/worlds/AutoWorld.py). Methods available for your world to call during -generation can be found in [BaseClasses](/BaseClasses.py) and [Fill](/Fill.py). Some examples and documentation -regarding the API can be found in the [world api doc](/docs/world%20api.md). -Before publishing, make sure to also check out [world maintainer.md](/docs/world%20maintainer.md). From 136a13aac796071299282ad72bf594a33f08eb57 Mon Sep 17 00:00:00 2001 From: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> Date: Fri, 4 Apr 2025 22:39:18 -0400 Subject: [PATCH 34/53] Docs: Include that DeathLink `cause` can be an empty string (#4729) --- docs/network protocol.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/network protocol.md b/docs/network protocol.md index 05a5334426..70c66543b5 100644 --- a/docs/network protocol.md +++ b/docs/network protocol.md @@ -756,8 +756,8 @@ Tags are represented as a list of strings, the common client tags follow: ### DeathLink A special kind of Bounce packet that can be supported by any AP game. It targets the tag "DeathLink" and carries the following data: -| Name | Type | Notes | -|--------|-------|--------------------------------------------------------------------------------------------------------------------------------------------------------| -| time | float | Unix Time Stamp of time of death. | -| cause | str | Optional. Text to explain the cause of death. When provided, or checked, this should contain the player name, ex. "Berserker was run over by a train." | -| source | str | Name of the player who first died. Can be a slot name, but can also be a name from within a multiplayer game. | +| Name | Type | Notes | +|--------|-------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| time | float | Unix Time Stamp of time of death. | +| cause | str | Optional. Text to explain the cause of death. When provided, or checked, if the string is non-empty, it should contain the player name, ex. "Berserker was run over by a train." | +| source | str | Name of the player who first died. Can be a slot name, but can also be a name from within a multiplayer game. | From 61e83a300b7bba39d4cbd63fd3c1b21389d53e58 Mon Sep 17 00:00:00 2001 From: black-sliver <59490463+black-sliver@users.noreply.github.com> Date: Sat, 5 Apr 2025 11:51:01 +0200 Subject: [PATCH 35/53] Clients: stop updating datapackage in persistent_storage (#4799) Still uses things that are in there but stops writing to it. --- CommonClient.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/CommonClient.py b/CommonClient.py index bf4b70ebe5..b622fb939b 100644 --- a/CommonClient.py +++ b/CommonClient.py @@ -625,9 +625,6 @@ class CommonContext: def consume_network_data_package(self, data_package: dict): self.update_data_package(data_package) - current_cache = Utils.persistent_load().get("datapackage", {}).get("games", {}) - current_cache.update(data_package["games"]) - Utils.persistent_store("datapackage", "games", current_cache) logger.info(f"Got new ID/Name DataPackage for {', '.join(data_package['games'])}") for game, game_data in data_package["games"].items(): Utils.store_data_package_for_checksum(game, game_data) From 0cce88cfbc47a2a3cb91ef4f9ceb246e025ea9e1 Mon Sep 17 00:00:00 2001 From: axe-y <58866768+axe-y@users.noreply.github.com> Date: Sat, 5 Apr 2025 08:19:54 -0400 Subject: [PATCH 36/53] DLC Quest: Fix more items than location with non existing start inventory (#4735) * DLC Quest Bug Fix Start inventory item that do not exist in the present world do not make more trap item to appear anymore * Update worlds/dlcquest/Items.py Co-authored-by: Mysteryem * DLC Quest Bug Fix did the recommendation of Mysteryem and made the item not exist in the pool of item created * DLC Quest Bug Fix did the recommendation of agilbert1412 and made a check by name instead of item to itemData * DLC Quest Bug Fix overcook failed test * DLC Quest Bug Fix re-type correctly a type hint --------- Co-authored-by: Mysteryem --- worlds/dlcquest/Items.py | 18 +++++++++++++----- worlds/dlcquest/__init__.py | 8 +++----- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/worlds/dlcquest/Items.py b/worlds/dlcquest/Items.py index 65b36fe617..550d92419b 100644 --- a/worlds/dlcquest/Items.py +++ b/worlds/dlcquest/Items.py @@ -98,14 +98,14 @@ def create_trap_items(world, world_options: Options.DLCQuestOptions, trap_needed return traps -def create_items(world, world_options: Options.DLCQuestOptions, locations_count: int, random: Random): +def create_items(world, world_options: Options.DLCQuestOptions, locations_count: int, excluded_items: list[str], random: Random): created_items = [] if world_options.campaign == Options.Campaign.option_basic or world_options.campaign == Options.Campaign.option_both: - create_items_basic(world_options, created_items, world) + create_items_basic(world_options, created_items, world, excluded_items) if (world_options.campaign == Options.Campaign.option_live_freemium_or_die or world_options.campaign == Options.Campaign.option_both): - create_items_lfod(world_options, created_items, world) + create_items_lfod(world_options, created_items, world, excluded_items) trap_items = create_trap_items(world, world_options, locations_count - len(created_items), random) created_items += trap_items @@ -113,8 +113,12 @@ def create_items(world, world_options: Options.DLCQuestOptions, locations_count: return created_items -def create_items_lfod(world_options, created_items, world): +def create_items_lfod(world_options, created_items, world, excluded_items): for item in items_by_group[Group.Freemium]: + if item.name in excluded_items: + excluded_items.remove(item) + continue + if item.has_any_group(Group.DLC): created_items.append(world.create_item(item)) if item.has_any_group(Group.Item) and world_options.item_shuffle == Options.ItemShuffle.option_shuffled: @@ -128,8 +132,12 @@ def create_items_lfod(world_options, created_items, world): create_coin(world_options, created_items, world, 889, 200, Group.Freemium) -def create_items_basic(world_options, created_items, world): +def create_items_basic(world_options, created_items, world, excluded_items): for item in items_by_group[Group.DLCQuest]: + if item.name in excluded_items: + excluded_items.remove(item.name) + continue + if item.has_any_group(Group.DLC): created_items.append(world.create_item(item)) if item.has_any_group(Group.Item) and world_options.item_shuffle == Options.ItemShuffle.option_shuffled: diff --git a/worlds/dlcquest/__init__.py b/worlds/dlcquest/__init__.py index 8c495ef698..4a8d0532bf 100644 --- a/worlds/dlcquest/__init__.py +++ b/worlds/dlcquest/__init__.py @@ -66,10 +66,10 @@ class DLCqworld(World): for location in self.multiworld.get_locations(self.player) if not location.advancement]) - items_to_exclude = [excluded_items + items_to_exclude = [excluded_items.name for excluded_items in self.multiworld.precollected_items[self.player]] - created_items = create_items(self, self.options, locations_count + len(items_to_exclude), self.multiworld.random) + created_items = create_items(self, self.options, locations_count, items_to_exclude, self.multiworld.random) self.multiworld.itempool += created_items @@ -84,9 +84,7 @@ class DLCqworld(World): else: early_items[self.player]["Movement Pack"] = 1 - for item in items_to_exclude: - if item in self.multiworld.itempool: - self.multiworld.itempool.remove(item) + def precollect_coinsanity(self): if self.options.campaign == Options.Campaign.option_basic: From 1749e22569164b9c58ad18c4595e8cd50e80ceb9 Mon Sep 17 00:00:00 2001 From: Nocallia <71400240+Nocallia@users.noreply.github.com> Date: Sat, 5 Apr 2025 08:20:51 -0400 Subject: [PATCH 37/53] Stardew: Fix minor grammar issues in Options (#4800) --- worlds/stardew_valley/options/options.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/worlds/stardew_valley/options/options.py b/worlds/stardew_valley/options/options.py index bc76c617b3..84026387c5 100644 --- a/worlds/stardew_valley/options/options.py +++ b/worlds/stardew_valley/options/options.py @@ -289,7 +289,7 @@ class BuildingProgression(Choice): Progressive: You will receive the buildings and will be able to build the first one of each type for free, once it is received. If you want more of the same building, it will cost the vanilla price. Cheap: Buildings will have a 50% discount - Very Cheap: Buildings will an 80% discount + Very Cheap: Buildings will have an 80% discount """ internal_name = "building_progression" display_name = "Building Progression" @@ -435,7 +435,7 @@ class Museumsanity(Choice): class Monstersanity(Choice): """Locations for slaying monsters? None: There are no checks for slaying monsters - One per category: Every category visible at the adventure guild gives one check + One per Category: Every category visible at the adventure guild gives one check One per Monster: Every unique monster gives one check Monster Eradication Goals: The Monster Eradication Goals each contain one check Short Monster Eradication Goals: The Monster Eradication Goals each contain one check, but are reduced by 60% @@ -498,7 +498,7 @@ class Cooksanity(Choice): class Chefsanity(NamedRange): """Locations for learning cooking recipes? Vanilla: All cooking recipes are learned normally - Queen of Sauce: Every Queen of sauce episode is a check, all queen of sauce recipes are items + Queen of Sauce: Every Queen of Sauce episode is a check, all Queen of Sauce recipes are items Purchases: Every purchasable recipe is a check Friendship: Recipes obtained from friendship are checks Skills: Recipes obtained from skills are checks @@ -589,7 +589,7 @@ class Booksanity(Choice): class Walnutsanity(OptionSet): - """Shuffle walnuts? + """Shuffle Walnuts? Puzzles: Walnuts obtained from solving a special puzzle or winning a minigame Bushes: Walnuts that are in a bush and can be collected by clicking it Dig Spots: Walnuts that are underground and must be digged up. Includes Journal scrap walnuts From cd4da368633461ffa8d9fcaeb3be014d3c2b7c78 Mon Sep 17 00:00:00 2001 From: CodeGorilla Date: Sat, 5 Apr 2025 08:21:38 -0500 Subject: [PATCH 38/53] GER: Only consider usable exits when calculating dead-ends (#4701) * Only consider usable exits when calculating whether or not a region is a dead-end * Update EntranceLookup unit tests * Add new dead-end test * Add additional explanation to the new test * minor formatting tweak based on review feedback --------- Co-authored-by: CodeGorilla <3672561+Ars-Ignis@users.noreply.github.com> --- entrance_rando.py | 9 +++++--- test/general/test_entrance_rando.py | 32 +++++++++++++++++++++++++++-- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/entrance_rando.py b/entrance_rando.py index ab329edf59..5ed2cd7645 100644 --- a/entrance_rando.py +++ b/entrance_rando.py @@ -50,13 +50,15 @@ class EntranceLookup: _random: random.Random _expands_graph_cache: dict[Entrance, bool] _coupled: bool + _usable_exits: set[Entrance] - def __init__(self, rng: random.Random, coupled: bool): + def __init__(self, rng: random.Random, coupled: bool, usable_exits: set[Entrance]): self.dead_ends = EntranceLookup.GroupLookup() self.others = EntranceLookup.GroupLookup() self._random = rng self._expands_graph_cache = {} self._coupled = coupled + self._usable_exits = usable_exits def _can_expand_graph(self, entrance: Entrance) -> bool: """ @@ -95,7 +97,8 @@ class EntranceLookup: # randomizable exits which are not reverse of the incoming entrance. # uncoupled mode is an exception because in this case going back in the door you just came in could # actually lead somewhere new - if not exit_.connected_region and (not self._coupled or exit_.name != entrance.name): + if (not exit_.connected_region and (not self._coupled or exit_.name != entrance.name) + and exit_ in self._usable_exits): self._expands_graph_cache[entrance] = True return True elif exit_.connected_region and exit_.connected_region not in visited: @@ -333,7 +336,6 @@ def randomize_entrances( start_time = time.perf_counter() er_state = ERPlacementState(world, coupled) - entrance_lookup = EntranceLookup(world.random, coupled) # similar to fill, skip validity checks on entrances if the game is beatable on minimal accessibility perform_validity_check = True @@ -349,6 +351,7 @@ def randomize_entrances( # used when membership checks are needed on the exit list, e.g. speculative sweep exits_set = set(exits) + entrance_lookup = EntranceLookup(world.random, coupled, exits_set) for entrance in er_targets: entrance_lookup.add(entrance) diff --git a/test/general/test_entrance_rando.py b/test/general/test_entrance_rando.py index 542b3b4ba7..56a059ecf2 100644 --- a/test/general/test_entrance_rando.py +++ b/test/general/test_entrance_rando.py @@ -65,8 +65,10 @@ class TestEntranceLookup(unittest.TestCase): """tests that get_targets shuffles targets between groups when requested""" multiworld = generate_test_multiworld() generate_disconnected_region_grid(multiworld, 5) + exits_set = set([ex for region in multiworld.get_regions(1) + for ex in region.exits if not ex.connected_region]) - lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True) + lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True, usable_exits=exits_set) er_targets = [entrance for region in multiworld.get_regions(1) for entrance in region.entrances if not entrance.parent_region] for entrance in er_targets: @@ -86,8 +88,10 @@ class TestEntranceLookup(unittest.TestCase): """tests that get_targets does not shuffle targets between groups when requested""" multiworld = generate_test_multiworld() generate_disconnected_region_grid(multiworld, 5) + exits_set = set([ex for region in multiworld.get_regions(1) + for ex in region.exits if not ex.connected_region]) - lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True) + lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True, usable_exits=exits_set) er_targets = [entrance for region in multiworld.get_regions(1) for entrance in region.entrances if not entrance.parent_region] for entrance in er_targets: @@ -99,6 +103,30 @@ class TestEntranceLookup(unittest.TestCase): group_order = [prev := group.randomization_group for group in retrieved_targets if prev != group.randomization_group] self.assertEqual([ERTestGroups.TOP, ERTestGroups.BOTTOM], group_order) + def test_selective_dead_ends(self): + """test that entrances that EntranceLookup has not been told to consider are ignored when finding dead-ends""" + multiworld = generate_test_multiworld() + generate_disconnected_region_grid(multiworld, 5) + exits_set = set([ex for region in multiworld.get_regions(1) + for ex in region.exits if not ex.connected_region + and ex.name != "region20_right" and ex.name != "region21_left"]) + + lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True, usable_exits=exits_set) + er_targets = [entrance for region in multiworld.get_regions(1) + for entrance in region.entrances if not entrance.parent_region and + entrance.name != "region20_right" and entrance.name != "region21_left"] + for entrance in er_targets: + lookup.add(entrance) + # region 20 is the bottom left corner of the grid, and therefore only has a right entrance from region 21 + # and a top entrance from region 15; since we've told lookup to ignore the right entrance from region 21, + # the top entrance from region 15 should be considered a dead-end + dead_end_region = multiworld.get_region("region20", 1) + for dead_end in dead_end_region.entrances: + if dead_end.name == "region20_top": + break + # there should be only this one dead-end + self.assertTrue(dead_end in lookup.dead_ends) + self.assertEqual(len(lookup.dead_ends), 1) class TestBakeTargetGroupLookup(unittest.TestCase): def test_lookup_generation(self): From ec75793ac3178fdd894c5dafd25a45d0bcb3c508 Mon Sep 17 00:00:00 2001 From: Benjamin S Wolf Date: Sat, 5 Apr 2025 06:50:52 -0700 Subject: [PATCH 39/53] Core: Add spoiler-only output mode (#4059) * Core: Add spoiler-only output mode * spoiler-only exceptions * Move new errors to mystery_argparse --- Generate.py | 13 +++++++++++++ Main.py | 11 ++++++++++- WebHostLib/generate.py | 1 + 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/Generate.py b/Generate.py index 4816155043..82386644e7 100644 --- a/Generate.py +++ b/Generate.py @@ -54,12 +54,22 @@ def mystery_argparse(): parser.add_argument("--skip_output", action="store_true", help="Skips generation assertion and output stages and skips multidata and spoiler output. " "Intended for debugging and testing purposes.") + parser.add_argument("--spoiler_only", action="store_true", + help="Skips generation assertion and multidata, outputting only a spoiler log. " + "Intended for debugging and testing purposes.") args = parser.parse_args() + + if args.skip_output and args.spoiler_only: + parser.error("Cannot mix --skip_output and --spoiler_only") + elif args.spoiler == 0 and args.spoiler_only: + parser.error("Cannot use --spoiler_only when --spoiler=0. Use --skip_output or set --spoiler to a different value") + if not os.path.isabs(args.weights_file_path): args.weights_file_path = os.path.join(args.player_files_path, args.weights_file_path) if not os.path.isabs(args.meta_file_path): args.meta_file_path = os.path.join(args.player_files_path, args.meta_file_path) args.plando: PlandoOptions = PlandoOptions.from_option_string(args.plando) + return args @@ -108,6 +118,8 @@ def main(args=None) -> Tuple[argparse.Namespace, int]: raise Exception("Cannot mix --sameoptions with --meta") else: meta_weights = None + + player_id = 1 player_files = {} for file in os.scandir(args.player_files_path): @@ -164,6 +176,7 @@ def main(args=None) -> Tuple[argparse.Namespace, int]: erargs.outputpath = args.outputpath erargs.skip_prog_balancing = args.skip_prog_balancing erargs.skip_output = args.skip_output + erargs.spoiler_only = args.spoiler_only erargs.name = {} erargs.csv_output = args.csv_output diff --git a/Main.py b/Main.py index d0e7a7f879..528db10c64 100644 --- a/Main.py +++ b/Main.py @@ -81,7 +81,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No del item_digits, location_digits, item_count, location_count # This assertion method should not be necessary to run if we are not outputting any multidata. - if not args.skip_output: + if not args.skip_output and not args.spoiler_only: AutoWorld.call_stage(multiworld, "assert_generate") AutoWorld.call_all(multiworld, "generate_early") @@ -224,6 +224,15 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No logger.info(f'Beginning output...') outfilebase = 'AP_' + multiworld.seed_name + if args.spoiler_only: + if args.spoiler > 1: + logger.info('Calculating playthrough.') + multiworld.spoiler.create_playthrough(create_paths=args.spoiler > 2) + + multiworld.spoiler.to_file(output_path('%s_Spoiler.txt' % outfilebase)) + logger.info('Done. Skipped multidata modification. Total time: %s', time.perf_counter() - start) + return multiworld + output = tempfile.TemporaryDirectory() with output as temp_dir: output_players = [player for player in multiworld.player_ids if AutoWorld.World.generate_output.__code__ diff --git a/WebHostLib/generate.py b/WebHostLib/generate.py index 0bd9f7e5e0..34033a0854 100644 --- a/WebHostLib/generate.py +++ b/WebHostLib/generate.py @@ -135,6 +135,7 @@ def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=Non {"bosses", "items", "connections", "texts"})) erargs.skip_prog_balancing = False erargs.skip_output = False + erargs.spoiler_only = False erargs.csv_output = False name_counter = Counter() From 5dfb9b28f796d03d3ee046eb9fd54566623abcd7 Mon Sep 17 00:00:00 2001 From: Mysteryem Date: Sat, 5 Apr 2025 14:59:39 +0100 Subject: [PATCH 40/53] Core: Improve iteration speed of Region.Register objects (#4583) Without implementing __iter__ directly, calling iter() on a Region.Register on Python 3.12 would return a new generator implemented as follows: ```py def __iter__(self) -> int: i = 0 try: while True: v = self[i] yield v i += 1 except IndexError: return None ``` This was determined by disassembling the returned generator with dis.dis() and then constructing a function that disassembles into the same bytecode. The iterator returned by `iter(self._list)` is faster than this generator, so using it slightly improves generation performance on average. Iteration of Region.Register objects is used a lot in `CollectionState.update_reachable_regions` in both of the private _update methods that get called. The performance gain here will vary depending on how many regions a world has and how many exits those regions have on average. For a game like Blasphemous, with a lot of regions and exits, generation of 10 template Blasphemous yamls with `--skip_output --seed 1` and progression balancing disabled went from 19.0s to 16.4s (14.2% reduction in generation duration). --- BaseClasses.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/BaseClasses.py b/BaseClasses.py index 3d0004806c..84e54d7e59 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -1106,6 +1106,9 @@ class Region: def __len__(self) -> int: return self._list.__len__() + def __iter__(self): + return iter(self._list) + # This seems to not be needed, but that's a bit suspicious. # def __del__(self): # self.clear() From a9b4d33cd285e11fc4b680e7edc0a515a9459d44 Mon Sep 17 00:00:00 2001 From: PinkSwitch <52474902+PinkSwitch@users.noreply.github.com> Date: Sat, 5 Apr 2025 09:07:37 -0500 Subject: [PATCH 41/53] Yoshi's Island: Fix Piece of Luigi not goaling until reset (#4709) --- worlds/yoshisisland/Client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/worlds/yoshisisland/Client.py b/worlds/yoshisisland/Client.py index 9b9e0ff52b..a7a234edb8 100644 --- a/worlds/yoshisisland/Client.py +++ b/worlds/yoshisisland/Client.py @@ -31,7 +31,7 @@ DEATHFLAG = WRAM_START + 0x00DB DEATHLINKRECV = WRAM_START + 0x00E0 GOALFLAG = WRAM_START + 0x14B6 -VALID_GAME_STATES = [0x0F, 0x10, 0x2C] +VALID_GAME_STATES = [0x0F, 0x10, 0x2C, 0x16, 0x1D] class YoshisIslandSNIClient(SNIClient): From 180265c8f4f63c367cdc1eea29d5f930290a1f04 Mon Sep 17 00:00:00 2001 From: LiquidCat64 <74896918+LiquidCat64@users.noreply.github.com> Date: Sat, 5 Apr 2025 08:27:51 -0600 Subject: [PATCH 42/53] CVCotM: Fix DeathLinks sent by a different instance of the same slot not being received. (#4726) * Fix same-slot-different-player DeathLinks not being received. * A few more comments. --- worlds/cvcotm/client.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/worlds/cvcotm/client.py b/worlds/cvcotm/client.py index 4db2c2faab..36019cd60e 100644 --- a/worlds/cvcotm/client.py +++ b/worlds/cvcotm/client.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, Set +from typing import TYPE_CHECKING, Set, Optional from .locations import BASE_ID, get_location_names_to_ids from .items import cvcotm_item_info, MAJORS_CLASSIFICATIONS from .locations import cvcotm_location_info @@ -91,6 +91,7 @@ class CastlevaniaCotMClient(BizHawkClient): patch_suffix = ".apcvcotm" sent_initial_packets: bool self_induced_death: bool + time_of_sent_death: Optional[float] local_checked_locations: Set[int] client_set_events = {flag_name: False for flag, flag_name in EVENT_FLAG_MAP.items()} killed_dracula_2: bool @@ -139,6 +140,7 @@ class CastlevaniaCotMClient(BizHawkClient): self.sent_initial_packets = False self.local_checked_locations = set() self.self_induced_death = False + self.time_of_sent_death = None self.client_set_events = {flag_name: False for flag, flag_name in EVENT_FLAG_MAP.items()} self.killed_dracula_2 = False self.won_battle_arena = False @@ -156,14 +158,16 @@ class CastlevaniaCotMClient(BizHawkClient): return if ctx.slot is None: return - if "DeathLink" in args["tags"] and args["data"]["source"] != ctx.slot_info[ctx.slot].name: + if "DeathLink" in args["tags"] and args["data"]["time"] != self.time_of_sent_death: if "cause" in args["data"]: cause = args["data"]["cause"] + # If the other game sent a death with a blank string for the cause, use the default death message. if cause == "": cause = f"{args['data']['source']} killed you without a word!" if len(cause) > ITEM_NAME_LIMIT + PLAYER_NAME_LIMIT: cause = cause[:ITEM_NAME_LIMIT + PLAYER_NAME_LIMIT] else: + # If the other game sent a death with no cause at all, use the default death message. cause = f"{args['data']['source']} killed you without a word!" # Highlight the player that killed us in the game's orange text. @@ -259,8 +263,13 @@ class CastlevaniaCotMClient(BizHawkClient): else: area_of_death = DEATHLINK_AREA_NAMES[area] + # Send the death. await ctx.send_death(f"{ctx.player_names[ctx.slot]} perished in {area_of_death}. Dracula has won!") + # Record the time in which the death was sent so when we receive the packet we can tell it wasn't our + # own death. ctx.on_deathlink overwrites it later, so it MUST be grabbed now. + self.time_of_sent_death = ctx.last_death_link + # Update the Dracula II and Battle Arena events already being done on past separate sessions for if the # player is running the Battle Arena and Dracula goal. if f"castlevania_cotm_events_{ctx.team}_{ctx.slot}" in ctx.stored_data: From 32629843860df18cc39b71b97404bf9525370edc Mon Sep 17 00:00:00 2001 From: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com> Date: Sat, 5 Apr 2025 17:47:16 +0200 Subject: [PATCH 43/53] The Witness: Option tooltip clarifications (#4807) * Missing colon * Clarify Panel Hunt * Unnecessary line break * that wasn't meant to be in here --- worlds/witness/options.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/worlds/witness/options.py b/worlds/witness/options.py index 1c2bc9324f..6814bad3e0 100644 --- a/worlds/witness/options.py +++ b/worlds/witness/options.py @@ -208,7 +208,7 @@ class EnvironmentalPuzzlesDifficulty(Choice): """ When "Shuffle Environmental Puzzles" is on, this setting governs which EPs are eligible for the location pool. - Eclipse: Every EP in the game is eligible, including the 1-hour-long "Theater Eclipse EP". - - Tedious Theater Eclipse EP is excluded from the location pool. + - Tedious: Theater Eclipse EP is excluded from the location pool. - Normal: several other difficult or long EPs are excluded as well. """ display_name = "Environmental Puzzles Difficulty" @@ -266,6 +266,8 @@ class VictoryCondition(Choice): class PanelHuntTotal(Range): """ + Only relevant if the Victory Condition is "Panel Hunt". + Sets the number of random panels that will get marked as "Panel Hunt" panels in the "Panel Hunt" game mode. """ display_name = "Total Panel Hunt panels" @@ -276,6 +278,8 @@ class PanelHuntTotal(Range): class PanelHuntRequiredPercentage(Range): """ + Only relevant if the Victory Condition is "Panel Hunt". + Determines the percentage of "Panel Hunt" panels that need to be solved to win. """ display_name = "Percentage of required Panel Hunt panels" @@ -286,12 +290,13 @@ class PanelHuntRequiredPercentage(Range): class PanelHuntPostgame(Choice): """ + Only relevant if the Victory Condition is "Panel Hunt". + In panel hunt, there are technically no postgame locations. Depending on your options, this can leave Mountain and Caves as two huge areas with Hunt Panels in them that cannot be reached until you get enough lasers to go through the very linear Mountain descent. Panel Hunt tends to be more fun when the world is open. This option lets you force anything locked by lasers to be disabled, and thus ineligible for Hunt Panels. To compensate, the respective mountain box solution (short box / long box) will be forced to be a Hunt Panel. - Does nothing if Panel Hunt is not your victory condition. Note: The "Mountain Lasers" option may also affect locations locked by challenge lasers if the only path to those locations leads through the Mountain Entry. """ @@ -307,6 +312,8 @@ class PanelHuntPostgame(Choice): class PanelHuntDiscourageSameAreaFactor(Range): """ + Only relevant if the Victory Condition is "Panel Hunt". + The greater this value, the less likely it is that many Hunt Panels show up in the same area. At 0, Hunt Panels will be selected randomly. @@ -321,6 +328,8 @@ class PanelHuntDiscourageSameAreaFactor(Range): class PanelHuntPlando(LocationSet): """ + Only relevant if the Victory Condition is "Panel Hunt". + Specify specific hunt panels you want for your panel hunt game. """ @@ -452,7 +461,6 @@ class VagueHints(Choice): If set to "stable", only location groups will be used. If location groups aren't implemented for the game your item ended up in, your hint will instead only tell you that the item is "somewhere in" that game. If set to "experimental", region names will be eligible as well, and you will never receive a "somewhere in" hint. Keep in mind that region names are not always intended to be comprehensible to players — only turn this on if you are okay with a bit of chaos. - The distinction does not matter in single player, as Witness implements location groups for every location. Also, please don't pester any devs about implementing location groups. Bring it up nicely, accept their response even if it is "No". From 0a44c3ec49e93a237ba8ea47dfdae090ecba8b17 Mon Sep 17 00:00:00 2001 From: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com> Date: Sat, 5 Apr 2025 17:48:18 +0200 Subject: [PATCH 44/53] The Witness: Move the Easter Egg Hunt option group lower so that the tooltip isn't cut off (#4789) --- worlds/witness/options.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/worlds/witness/options.py b/worlds/witness/options.py index 6814bad3e0..050bb7e904 100644 --- a/worlds/witness/options.py +++ b/worlds/witness/options.py @@ -625,7 +625,7 @@ if is_easter_time(): easter_special_option_group = OptionGroup("EASTER SPECIAL", [ EasterEggHunt, ]) - witness_option_groups = [easter_special_option_group, *witness_option_groups] + witness_option_groups.insert(2, easter_special_option_group) else: silly_options_group = next(group for group in witness_option_groups if group.name == "Silly Options") silly_options_group.options.append(EasterEggHunt) From bd8b8822acaf5fde40f6402375438ce78c298573 Mon Sep 17 00:00:00 2001 From: Mysteryem Date: Sat, 5 Apr 2025 16:50:19 +0100 Subject: [PATCH 45/53] Core: Pass maximum exploration states in distribute_items_restrictive (#4535) The base state passed to fill_restrictive should be as maximal as possible otherwise fill_restrictive has to repeatedly re-sweep and collect from advancement locations that were reachable from before fill_restrictive has placed a single item. This is not added within fill_restrictive itself because it is common for fills to be performed using a partial 'all_state', which is already a maximum exploration state. With --skip_output generation of every template yaml, except FF, KH and Shivers, this prevented repeatedly re-sweeping 576 advancement locations in every sweep within progression fill, reducing the generation time from 124s to 113s for me (8.8% reduction, averaged over 5 generations each). --- Fill.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/Fill.py b/Fill.py index efc8230fa1..fe39b74fbe 100644 --- a/Fill.py +++ b/Fill.py @@ -500,13 +500,15 @@ def distribute_items_restrictive(multiworld: MultiWorld, if prioritylocations: # "priority fill" - fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool, + maximum_exploration_state = sweep_from_pool(multiworld.state) + fill_restrictive(multiworld, maximum_exploration_state, prioritylocations, progitempool, single_player_placement=single_player, swap=False, on_place=mark_for_locking, name="Priority", one_item_per_player=True, allow_partial=True) if prioritylocations: # retry with one_item_per_player off because some priority fills can fail to fill with that optimization - fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool, + maximum_exploration_state = sweep_from_pool(multiworld.state) + fill_restrictive(multiworld, maximum_exploration_state, prioritylocations, progitempool, single_player_placement=single_player, swap=False, on_place=mark_for_locking, name="Priority Retry", one_item_per_player=False) accessibility_corrections(multiworld, multiworld.state, prioritylocations, progitempool) @@ -514,14 +516,15 @@ def distribute_items_restrictive(multiworld: MultiWorld, if progitempool: # "advancement/progression fill" + maximum_exploration_state = sweep_from_pool(multiworld.state) if panic_method == "swap": - fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=True, + fill_restrictive(multiworld, maximum_exploration_state, defaultlocations, progitempool, swap=True, name="Progression", single_player_placement=single_player) elif panic_method == "raise": - fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=False, + fill_restrictive(multiworld, maximum_exploration_state, defaultlocations, progitempool, swap=False, name="Progression", single_player_placement=single_player) elif panic_method == "start_inventory": - fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=False, + fill_restrictive(multiworld, maximum_exploration_state, defaultlocations, progitempool, swap=False, allow_partial=True, name="Progression", single_player_placement=single_player) if progitempool: for item in progitempool: From 4fea6b6e9b2564867eeef3bff943dcf7a00148cf Mon Sep 17 00:00:00 2001 From: Mysteryem Date: Sat, 5 Apr 2025 16:53:59 +0100 Subject: [PATCH 46/53] Core: Remove Location.__hash__ (#4274) `Location` does not override `__eq__` so should not override `__hash__`. With this patch, this makes operations on sets of locations slightly faster because they will use `object.__hash__` rather than `Location.__hash__`. `object.__hash__` is about 4 to 5 times faster than `Location.__hash__` for me. Generation often uses sets of locations, so this slightly speeds up generation. The only place I could find that was hashing locations directly was `WitnessLocationHint.__hash__`, but it has implemented a matching `__eq__`, so is fine. For security reasons, Python randomizes its hash seed each time it is started, so the result of the `hash()` function is nondeterministic and can't have been used by worlds for anything that needed to be deterministic and can't have been used to compare information hashed at generation time to information hashed by a client. --- BaseClasses.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/BaseClasses.py b/BaseClasses.py index 84e54d7e59..781fef0e33 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -1313,9 +1313,6 @@ class Location: 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)) - def __lt__(self, other: Location): return (self.player, self.name) < (other.player, other.name) From 9a5a02b654a26249e14993d95ae5ea71690172a6 Mon Sep 17 00:00:00 2001 From: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com> Date: Sat, 5 Apr 2025 18:05:58 +0200 Subject: [PATCH 47/53] MultiServer Extend datastore "update" operation to work on lists as well, acting as a pseudo "set union". #4666 --- MultiServer.py | 12 ++++++++---- docs/network protocol.md | 2 +- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/MultiServer.py b/MultiServer.py index f9ed34e2f7..f465b73928 100644 --- a/MultiServer.py +++ b/MultiServer.py @@ -66,9 +66,13 @@ def pop_from_container(container, value): return container -def update_dict(dictionary, entries): - dictionary.update(entries) - return dictionary +def update_container_unique(container, entries): + if isinstance(container, list): + existing_container_as_set = set(container) + container.extend([entry for entry in entries if entry not in existing_container_as_set]) + else: + container.update(entries) + return container def queue_gc(): @@ -109,7 +113,7 @@ modify_functions = { # lists/dicts: "remove": remove_from_list, "pop": pop_from_container, - "update": update_dict, + "update": update_container_unique, } diff --git a/docs/network protocol.md b/docs/network protocol.md index 70c66543b5..6688c101ab 100644 --- a/docs/network protocol.md +++ b/docs/network protocol.md @@ -470,7 +470,7 @@ The following operations can be applied to a datastorage key | right_shift | Applies a bitwise right-shift to the current value of the key by `value`. | | remove | List only: removes the first instance of `value` found in the list. | | pop | List or Dict: for lists it will remove the index of the `value` given. for dicts it removes the element with the specified key of `value`. | -| update | Dict only: Updates the dictionary with the specified elements given in `value` creating new keys, or updating old ones if they previously existed. | +| update | List or Dict: Adds the elements of `value` to the container if they weren't already present. In the case of a Dict, already present keys will have their corresponding values updated. | ### SetNotify Used to register your current session for receiving all [SetReply](#SetReply) packages of certain keys to allow your client to keep track of changes. From 7bdaaa25c14f1572a2bc7083d73bc43c8d19c9f4 Mon Sep 17 00:00:00 2001 From: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com> Date: Sat, 5 Apr 2025 18:06:30 +0200 Subject: [PATCH 48/53] Core: Prevent worlds from using LogicMixin incorrectly (having class variables without an init_mixin) (#3974) * Core: Prevent people from using LogicMixin incorrectly There's a world that ran into some issues because it defined its custom LogicMixin variables at the class level. This caused "instance bleed" when new CollectionState objects were created. I don't think there is ever a reason to have a non-function class variable on LogicMixin without also having `init_mixin`, so this asserts that this is the case. Tested: Doesn't fail any current worlds Correctly fails the world in question Also, not gonna call out that world because it was literally my fault for explaining it to them wrong :D * Verbose af * Update AutoWorld.py --- worlds/AutoWorld.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/worlds/AutoWorld.py b/worlds/AutoWorld.py index 0fcacc8ab3..d1f4a772ee 100644 --- a/worlds/AutoWorld.py +++ b/worlds/AutoWorld.py @@ -110,6 +110,16 @@ class AutoLogicRegister(type): elif not item_name.startswith("__"): if hasattr(CollectionState, item_name): raise Exception(f"Name conflict on Logic Mixin {name} trying to overwrite {item_name}") + + assert callable(function) or "init_mixin" in dct, ( + f"{name} defined class variable {item_name} without also having init_mixin.\n\n" + "Explanation:\n" + "Class variables that will be mutated need to be inintialized as instance variables in init_mixin.\n" + "If your LogicMixin variables aren't actually mutable / you don't intend to mutate them, " + "there is no point in using LogixMixin.\n" + "LogicMixin exists to track custom state variables that change when items are collected/removed." + ) + setattr(CollectionState, item_name, function) return new_class From 5c162bd7ce615b79fc2d83a637dc5f7955946479 Mon Sep 17 00:00:00 2001 From: Aaron Wagener Date: Sat, 5 Apr 2025 09:07:06 -0700 Subject: [PATCH 49/53] Core: add an is_event property to Item to match the one on Location (#3401) --- BaseClasses.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/BaseClasses.py b/BaseClasses.py index 781fef0e33..4db3917985 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -1416,6 +1416,10 @@ class Item: def flags(self) -> int: return self.classification.as_flag() + @property + def is_event(self) -> bool: + return self.code is None + def __eq__(self, other: object) -> bool: if not isinstance(other, Item): return NotImplemented From ef5cbd3ba314be549b2007a11918f78f11b04dd4 Mon Sep 17 00:00:00 2001 From: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> Date: Sat, 5 Apr 2025 12:30:08 -0400 Subject: [PATCH 50/53] Adventure: Set Victory Condition Earlier (#4810) --- worlds/adventure/__init__.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/worlds/adventure/__init__.py b/worlds/adventure/__init__.py index 9dab2ffcef..94f2b22e81 100644 --- a/worlds/adventure/__init__.py +++ b/worlds/adventure/__init__.py @@ -238,14 +238,12 @@ class AdventureWorld(World): def create_regions(self) -> None: create_regions(self.options, self.multiworld, self.player, self.dragon_rooms) - - set_rules = set_rules - - def generate_basic(self) -> None: self.multiworld.get_location("Chalice Home", self.player).place_locked_item( self.create_event("Victory", ItemClassification.progression)) self.multiworld.completion_condition[self.player] = lambda state: state.has("Victory", self.player) + set_rules = set_rules + def pre_fill(self): # Place empty items in filler locations here, to limit # the number of exported empty items and the density of stuff in overworld. From 4571ed7e2f3e0da5e7b1d008ea3efcd089a056cb Mon Sep 17 00:00:00 2001 From: Ishigh1 Date: Sat, 5 Apr 2025 18:35:00 +0200 Subject: [PATCH 51/53] Core: Made want_reply follow the specs in the docs #4750 --- MultiServer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MultiServer.py b/MultiServer.py index f465b73928..05e93e678d 100644 --- a/MultiServer.py +++ b/MultiServer.py @@ -2041,7 +2041,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict): value = func(value, operation["value"]) ctx.stored_data[args["key"]] = args["value"] = value targets = set(ctx.stored_data_notification_clients[args["key"]]) - if args.get("want_reply", True): + if args.get("want_reply", False): targets.add(client) if targets: ctx.broadcast(targets, [args]) From c2d8f2443e25d74e19ff1344f7d5e3950e4d13ce Mon Sep 17 00:00:00 2001 From: threeandthreee Date: Sat, 5 Apr 2025 12:39:31 -0400 Subject: [PATCH 52/53] LADX: more tracker support (#4355) * init * oops --- LinksAwakeningClient.py | 34 +++++++++++++++++++++------------- worlds/ladx/Tracker.py | 13 ++++++------- worlds/ladx/__init__.py | 2 ++ 3 files changed, 29 insertions(+), 20 deletions(-) diff --git a/LinksAwakeningClient.py b/LinksAwakeningClient.py index 2f2c94f68f..26a0d5533a 100644 --- a/LinksAwakeningClient.py +++ b/LinksAwakeningClient.py @@ -139,7 +139,7 @@ class RAGameboy(): def set_checks_range(self, checks_start, checks_size): self.checks_start = checks_start self.checks_size = checks_size - + def set_location_range(self, location_start, location_size, critical_addresses): self.location_start = location_start self.location_size = location_size @@ -237,7 +237,7 @@ class RAGameboy(): self.cache[start:start + len(hram_block)] = hram_block self.last_cache_read = time.time() - + async def read_memory_block(self, address: int, size: int): block = bytearray() remaining_size = size @@ -245,7 +245,7 @@ class RAGameboy(): chunk = await self.async_read_memory(address + len(block), remaining_size) remaining_size -= len(chunk) block += chunk - + return block async def read_memory_cache(self, addresses): @@ -514,8 +514,8 @@ class LinksAwakeningContext(CommonContext): magpie_task = None won = False - @property - def slot_storage_key(self): + @property + def slot_storage_key(self): return f"{self.slot_info[self.slot].name}_{storage_key}" def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str], magpie: typing.Optional[bool]) -> None: @@ -558,7 +558,7 @@ class LinksAwakeningContext(CommonContext): self.ui = LADXManager(self) self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI") - + async def send_new_entrances(self, entrances: typing.Dict[str, str]): # Store the entrances we find on the server for future sessions message = [{ @@ -597,12 +597,12 @@ class LinksAwakeningContext(CommonContext): logger.info("victory!") await self.send_msgs(message) self.won = True - + async def request_found_entrances(self): await self.send_msgs([{"cmd": "Get", "keys": [self.slot_storage_key]}]) - # Ask for updates so that players can co-op entrances in a seed - await self.send_msgs([{"cmd": "SetNotify", "keys": [self.slot_storage_key]}]) + # Ask for updates so that players can co-op entrances in a seed + await self.send_msgs([{"cmd": "SetNotify", "keys": [self.slot_storage_key]}]) async def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None: if self.ENABLE_DEATHLINK: @@ -638,12 +638,18 @@ class LinksAwakeningContext(CommonContext): if cmd == "Connected": self.game = self.slot_info[self.slot].game self.slot_data = args.get("slot_data", {}) - + # This is sent to magpie over local websocket to make its own connection + self.slot_data.update({ + "server_address": self.server_address, + "slot_name": self.player_names[self.slot], + "password": self.password, + }) + # TODO - use watcher_event if cmd == "ReceivedItems": for index, item in enumerate(args["items"], start=args["index"]): self.client.recvd_checks[index] = item - + if cmd == "Retrieved" and self.magpie_enabled and self.slot_storage_key in args["keys"]: self.client.gps_tracker.receive_found_entrances(args["keys"][self.slot_storage_key]) @@ -722,8 +728,10 @@ class LinksAwakeningContext(CommonContext): try: self.magpie.set_checks(self.client.tracker.all_checks) await self.magpie.set_item_tracker(self.client.item_tracker) - self.magpie.slot_data = self.slot_data - + if self.slot_data and "slot_data" in self.magpie.features and not self.magpie.has_sent_slot_data: + self.magpie.slot_data = self.slot_data + await self.magpie.send_slot_data() + if self.client.gps_tracker.needs_found_entrances: await self.request_found_entrances() self.client.gps_tracker.needs_found_entrances = False diff --git a/worlds/ladx/Tracker.py b/worlds/ladx/Tracker.py index 1842ceaec8..93b746328c 100644 --- a/worlds/ladx/Tracker.py +++ b/worlds/ladx/Tracker.py @@ -184,6 +184,7 @@ class MagpieBridge: ws = None features = [] slot_data = {} + has_sent_slot_data = False def use_entrance_tracker(self): return "entrances" in self.features \ @@ -199,7 +200,7 @@ class MagpieBridge: logger.info( f"Connected, supported features: {message['features']}") self.features = message["features"] - + await self.send_handshAck() if message["type"] == "sendFull": @@ -207,8 +208,6 @@ class MagpieBridge: await self.send_all_inventory() if "checks" in self.features: await self.send_all_checks() - if "slot_data" in self.features and self.slot_data: - await self.send_slot_data(self.slot_data) if self.use_entrance_tracker(): await self.send_gps(diff=False) @@ -220,7 +219,7 @@ class MagpieBridge: if the_id == "0x2A7": return "0x2A1-1" return the_id - + async def send_handshAck(self): if not self.ws: return @@ -288,17 +287,17 @@ class MagpieBridge: return await self.gps_tracker.send_entrances(self.ws, diff) - async def send_slot_data(self, slot_data): + async def send_slot_data(self): if not self.ws: return logger.debug("Sending slot_data to magpie.") message = { "type": "slot_data", - "slot_data": slot_data + "slot_data": self.slot_data } - await self.ws.send(json.dumps(message)) + self.has_sent_slot_data = True async def serve(self): async with websockets.serve(lambda w: self.handler(w), "", 17026, logger=logger): diff --git a/worlds/ladx/__init__.py b/worlds/ladx/__init__.py index 71c7fc6fd9..78ae1ce8ad 100644 --- a/worlds/ladx/__init__.py +++ b/worlds/ladx/__init__.py @@ -589,4 +589,6 @@ class LinksAwakeningWorld(World): for option, value in dataclasses.asdict(self.options).items() if option in slot_options_display_name }) + slot_data.update({"entrance_mapping": self.ladxr_logic.world_setup.entrance_mapping}) + return slot_data From 503999cb326f24f6a64363c79994d249360ab75b Mon Sep 17 00:00:00 2001 From: Silvris <58583688+Silvris@users.noreply.github.com> Date: Sat, 5 Apr 2025 11:46:24 -0500 Subject: [PATCH 53/53] Core: KivyMD and Launcher overhaul (#3934) Shifts the contents of `kvui.py`, and thus all CommonClient-based clients as well as Launcher, to using KivyMD. KivyMD is an extension for Kivy that is almost fully compatible with pre-existing Kivy components, while providing Material Design support for theming and overall visual design as well as useful pre-existing built in components such as Snackbars, Tooltips, and a built-in File Manager (not currently being used). As a part of this shift, the launcher was completely overhauled, adding the ability to filter the list of components down to each type of component, the ability to define favorite components and filter to them, and add shortcuts for launcher components to the desktop. An optional description field was added to Component for display within the new launcher. The theme (Light/Dark) and primary palette have also been exposed to users via client/user.kv. --- Launcher.py | 287 +++++++++++++--------- LinksAwakeningClient.py | 16 +- WargrooveClient.py | 8 +- data/client.kv | 57 ++++- data/launcher.kv | 142 +++++++++++ kvui.py | 464 ++++++++++++++++++++++++----------- requirements.txt | 3 + setup.py | 7 +- worlds/AutoSNIClient.py | 3 +- worlds/LauncherComponents.py | 5 +- worlds/sc2/ClientGui.py | 2 +- 11 files changed, 710 insertions(+), 284 deletions(-) create mode 100644 data/launcher.kv diff --git a/Launcher.py b/Launcher.py index 22c0944ab1..609c109470 100644 --- a/Launcher.py +++ b/Launcher.py @@ -1,5 +1,5 @@ """ -Archipelago launcher for bundled app. +Archipelago Launcher * if run with APBP as argument, launch corresponding client. * if run with executable as argument, run it passing argv[2:] as arguments @@ -8,7 +8,7 @@ Archipelago launcher for bundled app. Scroll down to components= to add components to the launcher as well as setup.py """ - +import os import argparse import itertools import logging @@ -20,10 +20,11 @@ import urllib.parse import webbrowser from os.path import isfile from shutil import which -from typing import Callable, Optional, Sequence, Tuple, Union +from typing import Callable, Optional, Sequence, Tuple, Union, Any if __name__ == "__main__": import ModuleUpdate + ModuleUpdate.update() import settings @@ -105,7 +106,8 @@ components.extend([ Component("Generate Template Options", func=generate_yamls), Component("Archipelago Website", func=lambda: webbrowser.open("https://archipelago.gg/")), Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2")), - Component("Unrated/18+ Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4")), + Component("Unrated/18+ Discord Server", icon="discord", + func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4")), Component("Browse Files", func=browse_files), ]) @@ -114,7 +116,7 @@ def handle_uri(path: str, launch_args: Tuple[str, ...]) -> None: url = urllib.parse.urlparse(path) queries = urllib.parse.parse_qs(url.query) launch_args = (path, *launch_args) - client_component = None + client_component = [] text_client_component = None if "game" in queries: game = queries["game"][0] @@ -122,49 +124,40 @@ def handle_uri(path: str, launch_args: Tuple[str, ...]) -> None: game = "Archipelago" for component in components: if component.supports_uri and component.game_name == game: - client_component = component + client_component.append(component) elif component.display_name == "Text Client": text_client_component = component + from kvui import MDButton, MDButtonText + from kivymd.uix.dialog import MDDialog, MDDialogHeadlineText, MDDialogContentContainer, MDDialogSupportingText + from kivymd.uix.divider import MDDivider + if client_component is None: run_component(text_client_component, *launch_args) return + else: + popup_text = MDDialogSupportingText(text="Select client to open and connect with.") + component_buttons = [MDDivider()] + for component in [text_client_component, *client_component]: + component_buttons.append(MDButton( + MDButtonText(text=component.display_name), + on_release=lambda *args, comp=component: run_component(comp, *launch_args), + style="text" + )) + component_buttons.append(MDDivider()) - from kvui import App, Button, BoxLayout, Label, Window + MDDialog( + # Headline + MDDialogHeadlineText(text="Connect to Multiworld"), + # Text + popup_text, + # Content + MDDialogContentContainer( + *component_buttons, + orientation="vertical" + ), - class Popup(App): - def __init__(self): - self.title = "Connect to Multiworld" - self.icon = r"data/icon.png" - super().__init__() - - def build(self): - layout = BoxLayout(orientation="vertical") - layout.add_widget(Label(text="Select client to open and connect with.")) - button_row = BoxLayout(orientation="horizontal", size_hint=(1, 0.4)) - - text_client_button = Button( - text=text_client_component.display_name, - on_release=lambda *args: run_component(text_client_component, *launch_args) - ) - button_row.add_widget(text_client_button) - - game_client_button = Button( - text=client_component.display_name, - on_release=lambda *args: run_component(client_component, *launch_args) - ) - button_row.add_widget(game_client_button) - - layout.add_widget(button_row) - - return layout - - def _stop(self, *largs): - # see run_gui Launcher _stop comment for details - self.root_window.close() - super()._stop(*largs) - - Popup().run() + ).open() def identify(path: Union[None, str]) -> Tuple[Union[None, str], Union[None, Component]]: @@ -220,100 +213,163 @@ def launch(exe, in_terminal=False): subprocess.Popen(exe) +def create_shortcut(button: Any, component: Component) -> None: + from pyshortcuts import make_shortcut + script = sys.argv[0] + wkdir = Utils.local_path() + + script = f"{script} \"{component.display_name}\"" + make_shortcut(script, name=f"Archipelago {component.display_name}", icon=local_path("data", "icon.ico"), + startmenu=False, terminal=False, working_dir=wkdir) + button.menu.dismiss() + + refresh_components: Optional[Callable[[], None]] = None -def run_gui(): - from kvui import App, ContainerLayout, GridLayout, Button, Label, ScrollBox, Widget, ApAsyncImage +def run_gui(path: str, args: Any) -> None: + from kvui import (ThemedApp, MDFloatLayout, MDGridLayout, MDButton, MDLabel, MDButtonText, ScrollBox, ApAsyncImage) + from kivy.properties import ObjectProperty from kivy.core.window import Window - from kivy.uix.relativelayout import RelativeLayout + from kivy.metrics import dp + from kivymd.uix.button import MDIconButton + from kivymd.uix.card import MDCard + from kivymd.uix.menu import MDDropdownMenu + from kivymd.uix.relativelayout import MDRelativeLayout + from kivymd.uix.snackbar import MDSnackbar, MDSnackbarText - class Launcher(App): + from kivy.lang.builder import Builder + + class LauncherCard(MDCard): + component: Component | None + image: str + context_button: MDIconButton = ObjectProperty(None) + + def __init__(self, *args, component: Component | None = None, image_path: str = "", **kwargs): + self.component = component + self.image = image_path + super().__init__(args, kwargs) + + + class Launcher(ThemedApp): base_title: str = "Archipelago Launcher" - container: ContainerLayout - grid: GridLayout - _tool_layout: Optional[ScrollBox] = None - _client_layout: Optional[ScrollBox] = None + top_screen: MDFloatLayout = ObjectProperty(None) + navigation: MDGridLayout = ObjectProperty(None) + grid: MDGridLayout = ObjectProperty(None) + button_layout: ScrollBox = ObjectProperty(None) + cards: list[LauncherCard] + current_filter: Sequence[str | Type] | None - def __init__(self, ctx=None): + def __init__(self, ctx=None, path=None, args=None): self.title = self.base_title + " " + Utils.__version__ self.ctx = ctx self.icon = r"data/icon.png" + self.favorites = [] + self.launch_uri = path + self.launch_args = args + self.cards = [] + self.current_filter = (Type.CLIENT, Type.TOOL, Type.ADJUSTER, Type.MISC) + persistent = Utils.persistent_load() + if "launcher" in persistent: + if "favorites" in persistent["launcher"]: + self.favorites.extend(persistent["launcher"]["favorites"]) + if "filter" in persistent["launcher"]: + if persistent["launcher"]["filter"]: + filters = [] + for filter in persistent["launcher"]["filter"].split(", "): + if filter == "favorites": + filters.append(filter) + else: + filters.append(Type[filter]) + self.current_filter = filters super().__init__() - def _refresh_components(self) -> None: + def set_favorite(self, caller): + if caller.component.display_name in self.favorites: + self.favorites.remove(caller.component.display_name) + caller.icon = "star-outline" + else: + self.favorites.append(caller.component.display_name) + caller.icon = "star" - def build_button(component: Component) -> Widget: + def build_card(self, component: Component) -> LauncherCard: + """ + Builds a card widget for a given component. + + :param component: The component associated with the button. + + :return: The created Card Widget. """ - Builds a button widget for a given component. + button_card = LauncherCard(component=component, + image_path=icon_paths[component.icon]) - Args: - component (Component): The component associated with the button. + def open_menu(caller): + caller.menu.open() - Returns: - None. The button is added to the parent grid layout. + menu_items = [ + { + "text": "Add shortcut on desktop", + "leading_icon": "laptop", + "on_release": lambda: create_shortcut(button_card.context_button, component) + } + ] + button_card.context_button.menu = MDDropdownMenu(caller=button_card.context_button, items=menu_items) + button_card.context_button.bind(on_release=open_menu) - """ - button = Button(text=component.display_name, size_hint_y=None, height=40) - button.component = component - button.bind(on_release=self.component_action) - if component.icon != "icon": - image = ApAsyncImage(source=icon_paths[component.icon], - size=(38, 38), size_hint=(None, 1), pos=(5, 0)) - box_layout = RelativeLayout(size_hint_y=None, height=40) - box_layout.add_widget(button) - box_layout.add_widget(image) - return box_layout - return button + return button_card + + def _refresh_components(self, type_filter: Sequence[str | Type] | None = None) -> None: + if not type_filter: + type_filter = [Type.CLIENT, Type.ADJUSTER, Type.TOOL, Type.MISC] + favorites = "favorites" in type_filter # clear before repopulating - assert self._tool_layout and self._client_layout, "must call `build` first" - tool_children = reversed(self._tool_layout.layout.children) + assert self.button_layout, "must call `build` first" + tool_children = reversed(self.button_layout.layout.children) for child in tool_children: - self._tool_layout.layout.remove_widget(child) - client_children = reversed(self._client_layout.layout.children) - for child in client_children: - self._client_layout.layout.remove_widget(child) + self.button_layout.layout.remove_widget(child) - _tools = {c.display_name: c for c in components if c.type == Type.TOOL} - _clients = {c.display_name: c for c in components if c.type == Type.CLIENT} - _adjusters = {c.display_name: c for c in components if c.type == Type.ADJUSTER} - _miscs = {c.display_name: c for c in components if c.type == Type.MISC} + cards = [card for card in self.cards if card.component.type in type_filter + or favorites and card.component.display_name in self.favorites] - for (tool, client) in itertools.zip_longest(itertools.chain( - _tools.items(), _miscs.items(), _adjusters.items() - ), _clients.items()): - # column 1 - if tool: - self._tool_layout.layout.add_widget(build_button(tool[1])) - # column 2 - if client: - self._client_layout.layout.add_widget(build_button(client[1])) + self.current_filter = type_filter + + for card in cards: + self.button_layout.layout.add_widget(card) + + def filter_clients(self, caller): + self._refresh_components(caller.type) def build(self): - self.container = ContainerLayout() - self.grid = GridLayout(cols=2) - self.container.add_widget(self.grid) - self.grid.add_widget(Label(text="General", size_hint_y=None, height=40)) - self.grid.add_widget(Label(text="Clients", size_hint_y=None, height=40)) - self._tool_layout = ScrollBox() - self._tool_layout.layout.orientation = "vertical" - self.grid.add_widget(self._tool_layout) - self._client_layout = ScrollBox() - self._client_layout.layout.orientation = "vertical" - self.grid.add_widget(self._client_layout) - - self._refresh_components() + self.top_screen = Builder.load_file(Utils.local_path("data/launcher.kv")) + self.grid = self.top_screen.ids.grid + self.navigation = self.top_screen.ids.navigation + self.button_layout = self.top_screen.ids.button_layout + self.set_colors() + self.top_screen.md_bg_color = self.theme_cls.backgroundColor global refresh_components refresh_components = self._refresh_components Window.bind(on_drop_file=self._on_drop_file) - return self.container + for component in components: + self.cards.append(self.build_card(component)) + + self._refresh_components(self.current_filter) + + return self.top_screen + + def on_start(self): + if self.launch_uri: + handle_uri(self.launch_uri, self.launch_args) + self.launch_uri = None + self.launch_args = None @staticmethod def component_action(button): + MDSnackbar(MDSnackbarText(text="Opening in a new window..."), y=dp(24), pos_hint={"center_x": 0.5}, + size_hint_x=0.5).open() if button.component.func: button.component.func() else: @@ -333,7 +389,13 @@ def run_gui(): self.root_window.close() super()._stop(*largs) - Launcher().run() + def on_stop(self): + Utils.persistent_store("launcher", "favorites", self.favorites) + Utils.persistent_store("launcher", "filter", ", ".join(filter.name if isinstance(filter, Type) else filter + for filter in self.current_filter)) + super().on_stop() + + Launcher(path=path, args=args).run() # avoiding Launcher reference leak # and don't try to do something with widgets after window closed @@ -360,16 +422,14 @@ def main(args: Optional[Union[argparse.Namespace, dict]] = None): path = args.get("Patch|Game|Component|url", None) if path is not None: - if path.startswith("archipelago://"): - handle_uri(path, args.get("args", ())) - return - file, component = identify(path) - if file: - args['file'] = file - if component: - args['component'] = component - if not component: - logging.warning(f"Could not identify Component responsible for {path}") + if not path.startswith("archipelago://"): + file, component = identify(path) + if file: + args['file'] = file + if component: + args['component'] = component + if not component: + logging.warning(f"Could not identify Component responsible for {path}") if args["update_settings"]: update_settings() @@ -378,7 +438,7 @@ def main(args: Optional[Union[argparse.Namespace, dict]] = None): elif "component" in args: run_component(args["component"], *args["args"]) elif not args["update_settings"]: - run_gui() + run_gui(path, args.get("args", ())) if __name__ == '__main__': @@ -400,6 +460,7 @@ if __name__ == '__main__': main(parser.parse_args()) from worlds.LauncherComponents import processes + for process in processes: # we await all child processes to close before we tear down the process host # this makes it feel like each one is its own program, as the Launcher is closed now diff --git a/LinksAwakeningClient.py b/LinksAwakeningClient.py index 26a0d5533a..bdfaa74625 100644 --- a/LinksAwakeningClient.py +++ b/LinksAwakeningClient.py @@ -529,9 +529,7 @@ class LinksAwakeningContext(CommonContext): def run_gui(self) -> None: import webbrowser - import kvui - from kvui import Button, GameManager - from kivy.uix.image import Image + from kvui import GameManager, ImageButton class LADXManager(GameManager): logging_pairs = [ @@ -544,16 +542,10 @@ class LinksAwakeningContext(CommonContext): b = super().build() if self.ctx.magpie_enabled: - button = Button(text="", size=(30, 30), size_hint_x=None, - on_press=lambda _: webbrowser.open('https://magpietracker.us/?enable_autotracker=1')) - image = Image(size=(16, 16), texture=magpie_logo()) - button.add_widget(image) - - def set_center(_, center): - image.center = center - button.bind(center=set_center) - + button = ImageButton(texture=magpie_logo(), fit_mode="cover", image_size=(32, 32), size_hint_x=None, + on_press=lambda _: webbrowser.open('https://magpietracker.us/?enable_autotracker=1')) self.connect_layout.add_widget(button) + return b self.ui = LADXManager(self) diff --git a/WargrooveClient.py b/WargrooveClient.py index f900e05e3f..595a221cd2 100644 --- a/WargrooveClient.py +++ b/WargrooveClient.py @@ -214,17 +214,11 @@ class WargrooveContext(CommonContext): def run_gui(self): """Import kivy UI system and start running it as self.ui_task.""" from kvui import GameManager, HoverBehavior, ServerToolTip - from kivy.uix.tabbedpanel import TabbedPanelItem + from kivymd.uix.tab import MDTabsItem, MDTabsItemText from kivy.lang import Builder - from kivy.uix.button import Button from kivy.uix.togglebutton import ToggleButton from kivy.uix.boxlayout import BoxLayout - from kivy.uix.gridlayout import GridLayout - from kivy.uix.image import AsyncImage, Image - from kivy.uix.stacklayout import StackLayout from kivy.uix.label import Label - from kivy.properties import ColorProperty - from kivy.uix.image import Image import pkgutil class TrackerLayout(BoxLayout): diff --git a/data/client.kv b/data/client.kv index f0f31769e4..0974258d6c 100644 --- a/data/client.kv +++ b/data/client.kv @@ -14,23 +14,50 @@ salmon: "FA8072" # typically trap item white: "FFFFFF" # not used, if you want to change the generic text color change color in Label orange: "FF7700" # Used for command echo -