From 116ab2286ad95fe4a43fbc06247d4f0ba42e6e34 Mon Sep 17 00:00:00 2001 From: Justus Lind Date: Sat, 28 Mar 2026 03:36:36 +1000 Subject: [PATCH 01/26] Muse Dash: Add support for Wuthering Waves Pioneer Podcast and Ay-Aye Horse (#6071) --- worlds/musedash/MuseDashCollection.py | 2 ++ worlds/musedash/MuseDashData.py | 13 +++++++++++-- worlds/musedash/archipelago.json | 2 +- worlds/musedash/test/TestDifficultyRanges.py | 3 ++- 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/worlds/musedash/MuseDashCollection.py b/worlds/musedash/MuseDashCollection.py index 4c23a5c64f..472b4c95a1 100644 --- a/worlds/musedash/MuseDashCollection.py +++ b/worlds/musedash/MuseDashCollection.py @@ -15,6 +15,7 @@ class MuseDashCollections: "Default Music", "Budget Is Burning: Nano Core", "Budget Is Burning Vol.1", + "Wuthering Waves Pioneer Podcast", ] MUSE_PLUS_DLC: str = "Muse Plus" @@ -40,6 +41,7 @@ class MuseDashCollections: "Heart Message feat. Aoi Tokimori Secret", "Meow Rock feat. Chun Ge, Yuan Shen", "Stra Stella Secret", + "Musepyoi Legend", ] song_items = SONG_DATA diff --git a/worlds/musedash/MuseDashData.py b/worlds/musedash/MuseDashData.py index 86486a4929..6cb294bd98 100644 --- a/worlds/musedash/MuseDashData.py +++ b/worlds/musedash/MuseDashData.py @@ -696,11 +696,20 @@ SONG_DATA: Dict[str, SongData] = { "Otsukimi Koete Otsukiai": SongData(2900820, "43-70", "MD Plus Project", True, 6, 8, 10), "Obenkyou Time": SongData(2900821, "43-71", "MD Plus Project", False, 6, 8, 11), "Retry Now": SongData(2900822, "43-72", "MD Plus Project", False, 3, 6, 9), - "Master Bancho's Sushi Class ": SongData(2900823, "93-0", "Welcome to the Blue Hole!", False, None, None, None), + "Master Bancho's Sushi Class": SongData(2900823, "93-0", "Welcome to the Blue Hole!", False, None, 7, None), "CHAOTiC BATTLE": SongData(2900824, "94-0", "Cosmic Radio 2025", False, 7, 9, 11), "FATAL GAME": SongData(2900825, "94-1", "Cosmic Radio 2025", False, 3, 6, 9), "Aria": SongData(2900826, "94-2", "Cosmic Radio 2025", False, 4, 6, 9), "+1 UNKNOWN -NUMBER": SongData(2900827, "94-3", "Cosmic Radio 2025", True, 4, 7, 10), "To the Beyond, from the Nameless Seaside": SongData(2900828, "94-4", "Cosmic Radio 2025", False, 5, 8, 10), "REK421": SongData(2900829, "94-5", "Cosmic Radio 2025", True, 7, 9, 11), -} + "Musepyoi Legend": SongData(2900830, "95-0", "Ay-Aye Horse", True, None, None, None), + "Not Regret": SongData(2900831, "95-1", "Ay-Aye Horse", False, 7, 9, 11), + "-Toryanna-": SongData(2900832, "95-2", "Ay-Aye Horse", True, 4, 6, 9), + "Icecream Angels": SongData(2900833, "95-3", "Ay-Aye Horse", False, 3, 6, 9), + "MEGA TSKR": SongData(2900834, "95-4", "Ay-Aye Horse", False, 4, 7, 10), + "777 Vocal ver.": SongData(2900835, "95-5", "Ay-Aye Horse", False, 7, 9, 11), + "Chasing Daylight": SongData(2900836, "96-0", "Wuthering Waves Pioneer Podcast", False, 3, 5, 8), + "CATCH ME IF YOU CAN": SongData(2900837, "96-1", "Wuthering Waves Pioneer Podcast", False, 4, 6, 9), + "RUNNING FOR YOUR LIFE": SongData(2900838, "96-2", "Wuthering Waves Pioneer Podcast", False, 2, 5, 8), +} \ No newline at end of file diff --git a/worlds/musedash/archipelago.json b/worlds/musedash/archipelago.json index dea7846b4f..0580d85e77 100644 --- a/worlds/musedash/archipelago.json +++ b/worlds/musedash/archipelago.json @@ -1,6 +1,6 @@ { "game": "Muse Dash", "authors": ["DeamonHunter"], - "world_version": "1.5.29", + "world_version": "1.5.30", "minimum_ap_version": "0.6.3" } \ No newline at end of file diff --git a/worlds/musedash/test/TestDifficultyRanges.py b/worlds/musedash/test/TestDifficultyRanges.py index 27798243a5..f41e807171 100644 --- a/worlds/musedash/test/TestDifficultyRanges.py +++ b/worlds/musedash/test/TestDifficultyRanges.py @@ -10,6 +10,7 @@ class DifficultyRanges(MuseDashTestBase): "PeroPero in the Universe", "umpopoff", "P E R O P E R O Brother Dance", + "Master Bancho's Sushi Class", ] def test_all_difficulty_ranges(self) -> None: @@ -78,7 +79,7 @@ class DifficultyRanges(MuseDashTestBase): # Some songs are weird and have less than the usual 3 difficulties. # So this override is to avoid failing on these songs. - if song_name in ("umpopoff", "P E R O P E R O Brother Dance"): + if song_name in ("umpopoff", "P E R O P E R O Brother Dance", "Master Bancho's Sushi Class"): self.assertTrue(song.easy is None and song.hard is not None and song.master is None, f"Song '{song_name}' difficulty not set when it should be.") else: From 4276c6d6b01542181b79ea4678b5e857fae3e86d Mon Sep 17 00:00:00 2001 From: Phaneros <31861583+MatthewMarinets@users.noreply.github.com> Date: Fri, 27 Mar 2026 14:45:38 -0700 Subject: [PATCH 02/26] sc2: Fixing random fill errors in unit tests (#6045) --- worlds/sc2/docs/setup_en.md | 33 ++++++++++++---- worlds/sc2/locations.py | 16 ++------ worlds/sc2/pool_filter.py | 20 +++++----- worlds/sc2/regions.py | 2 +- worlds/sc2/rules.py | 10 ++--- worlds/sc2/test/slow_tests.py | 52 +++++++++++++++++++++++++ worlds/sc2/test/test_base.py | 24 ++++++------ worlds/sc2/test/test_generation.py | 62 ++++++++++++++++-------------- worlds/sc2/test/test_regions.py | 23 +++++------ 9 files changed, 154 insertions(+), 88 deletions(-) create mode 100644 worlds/sc2/test/slow_tests.py diff --git a/worlds/sc2/docs/setup_en.md b/worlds/sc2/docs/setup_en.md index d6f9f6cda5..e70c15d28f 100644 --- a/worlds/sc2/docs/setup_en.md +++ b/worlds/sc2/docs/setup_en.md @@ -156,15 +156,17 @@ This page includes all data associated with all games. ## How do I join a MultiWorld game? -1. Run ArchipelagoStarcraft2Client.exe. +1. Run ArchipelagoLauncher.exe. - macOS users should instead follow the instructions found at ["Running in macOS"](#running-in-macos) for this step only. -2. In the Archipelago tab, type `/connect [server IP]`. +2. Search for the Starcraft 2 Client in the launcher to open the game-specific client + - Alternatively, steps 1 and 2 can be combined by providing the `"Starcraft 2 Client"` launch argument to the launcher. +3. In the Archipelago tab, type `/connect [server IP]`. - If you're running through the website, the server IP should be displayed near the top of the room page. - The server IP may also be typed into the top bar, and then clicking "Connect" -3. Type your slot name from your YAML when prompted. -4. If the server has a password, enter that when prompted. -5. Once connected, switch to the 'StarCraft 2 Launcher' tab in the client. There, you can see all the missions in your +4. Type your slot name from your YAML when prompted. +5. If the server has a password, enter that when prompted. +6. Once connected, switch to the 'StarCraft 2 Launcher' tab in the client. There, you can see all the missions in your world. Unreachable missions will have greyed-out text. Completed missions (all locations collected) will have white text. @@ -173,7 +175,22 @@ Mission buttons will have a color corresponding to the faction you play as in th Click on an available mission to start it. -## The game isn't launching when I try to start a mission. +## Troubleshooting + +### I can't connect to my seed. + +Rooms on the Archipelago website go to sleep after two hours of inactivity; reload or refresh the room page +to start them back up. +When restarting the room, the connection port may change (the numbers after "archipelago.gg:"), +make sure that is accurate. +Your slot name should be displayed on the room page as well; make sure that exactly matches the slot name you +type into your client, and note that it is case-sensitive. + +If none of these things solve the problem, visit the [Discord](https://discord.com/invite/8Z65BR2) and check +the #software-announcements channel to see if there's a listed outage, or visit the #starcraft-2 channel for +tech support. + +### The game isn't launching when I try to start a mission. Usually, this is caused by the mod files not being downloaded. Make sure you have run `/download_data` in the Archipelago tab before playing. @@ -183,12 +200,12 @@ Make sure that you are running an up-to-date version of the client. Check the [Archipelago Releases Page](https://github.com/ArchipelagoMW/Archipelago/releases) to look up what the latest version is (RC releases are not necessary; that stands for "Release Candidate"). -If these things are in order, check the log file for issues (stored at `[Archipelago Directory]/logs/Starcraft2Client.txt`). +If these things are in order, check the log file for issues (stored at `[Archipelago Directory]/logs/SC2Client_.txt`). If you can't figure out the log file, visit our [Discord's](https://discord.com/invite/8Z65BR2) tech-support channel for help. Please include a specific description of what's going wrong and attach your log file to your message. -## My keyboard shortcuts profile is not available when I play *StarCraft 2 Archipelago*. +### My keyboard shortcuts profile is not available when I play *StarCraft 2 Archipelago*. For your keyboard shortcuts profile to work in Archipelago, you need to copy your shortcuts file from `Documents/StarCraft II/Accounts/######/Hotkeys` to `Documents/StarCraft II/Hotkeys`. diff --git a/worlds/sc2/locations.py b/worlds/sc2/locations.py index 318d52f856..ddc188bad9 100644 --- a/worlds/sc2/locations.py +++ b/worlds/sc2/locations.py @@ -249,7 +249,6 @@ def get_locations(world: Optional["SC2World"]) -> Tuple[LocationData, ...]: LocationType.VICTORY, lambda state: ( logic.terran_common_unit(state) - and logic.terran_defense_rating(state, True) >= 2 and (adv_tactics or logic.terran_basic_anti_air(state)) ), ), @@ -271,10 +270,7 @@ def get_locations(world: Optional["SC2World"]) -> Tuple[LocationData, ...]: "Third Group Rescued", SC2WOL_LOC_ID_OFFSET + 303, LocationType.VANILLA, - lambda state: ( - logic.terran_common_unit(state) - and logic.terran_defense_rating(state, True) >= 2 - ), + logic.terran_common_unit, ), make_location_data( SC2Mission.ZERO_HOUR.mission_name, @@ -320,20 +316,14 @@ def get_locations(world: Optional["SC2World"]) -> Tuple[LocationData, ...]: "Hold Just a Little Longer", SC2WOL_LOC_ID_OFFSET + 309, LocationType.EXTRA, - lambda state: ( - logic.terran_common_unit(state) - and logic.terran_defense_rating(state, True) >= 2 - ), + logic.terran_common_unit, ), make_location_data( SC2Mission.ZERO_HOUR.mission_name, "Cavalry's on the Way", SC2WOL_LOC_ID_OFFSET + 310, LocationType.EXTRA, - lambda state: ( - logic.terran_common_unit(state) - and logic.terran_defense_rating(state, True) >= 2 - ), + logic.terran_common_unit, ), make_location_data( SC2Mission.EVACUATION.mission_name, diff --git a/worlds/sc2/pool_filter.py b/worlds/sc2/pool_filter.py index 0ffa08e010..abecf8264c 100644 --- a/worlds/sc2/pool_filter.py +++ b/worlds/sc2/pool_filter.py @@ -182,7 +182,7 @@ class ValidInventory: del self.logical_inventory[item.name] item.filter_flags |= remove_flag return "" - + def remove_child_items( parent_item: StarcraftItem, remove_flag: ItemFilterFlags = ItemFilterFlags.FilterExcluded, @@ -247,13 +247,13 @@ class ValidInventory: # Limit the maximum number of upgrades if max_upgrades_per_unit != -1: - for group_name, group_items in group_to_item.items(): - self.world.random.shuffle(group_to_item[group]) + for group_items in group_to_item.values(): + self.world.random.shuffle(group_items) cull_items_over_maximum(group_items, max_upgrades_per_unit) - + # Requesting minimum upgrades for items that have already been locked/placed when minimum required if min_upgrades_per_unit != -1: - for group_name, group_items in group_to_item.items(): + for group_items in group_to_item.values(): self.world.random.shuffle(group_items) request_minimum_items(group_items, min_upgrades_per_unit) @@ -349,7 +349,7 @@ class ValidInventory: ItemFilterFlags.Removed not in item.filter_flags and ((ItemFilterFlags.Unexcludable|ItemFilterFlags.Excluded) & item.filter_flags) != ItemFilterFlags.Excluded ) - + # Actually remove culled items; we won't re-add them inventory = [ item for item in inventory @@ -373,7 +373,7 @@ class ValidInventory: item for item in cullable_items if not ((ItemFilterFlags.Removed|ItemFilterFlags.Uncullable) & item.filter_flags) ] - + # Handle too many requested if current_inventory_size - start_inventory_size > inventory_size - filler_amount: for item in inventory: @@ -414,7 +414,7 @@ class ValidInventory: removable_transport_hooks = [item for item in inventory_transport_hooks if not (ItemFilterFlags.Unexcludable & item.filter_flags)] if len(inventory_transport_hooks) > 1 and removable_transport_hooks: inventory.remove(removable_transport_hooks[0]) - + # Weapon/Armour upgrades def exclude_wa(prefix: str) -> List[StarcraftItem]: return [ @@ -439,7 +439,7 @@ class ValidInventory: inventory = exclude_wa(item_names.PROTOSS_GROUND_UPGRADE_PREFIX) if used_item_names.isdisjoint(item_groups.protoss_air_wa): inventory = exclude_wa(item_names.PROTOSS_AIR_UPGRADE_PREFIX) - + # Part 4: Last-ditch effort to reduce inventory size; upgrades can go in start inventory current_inventory_size = len(inventory) precollect_items = current_inventory_size - inventory_size - start_inventory_size - filler_amount @@ -453,7 +453,7 @@ class ValidInventory: for item in promotable[:precollect_items]: item.filter_flags |= ItemFilterFlags.StartInventory start_inventory_size += 1 - + assert current_inventory_size - start_inventory_size <= inventory_size - filler_amount, ( f"Couldn't reduce inventory to fit. target={inventory_size}, poolsize={current_inventory_size}, " f"start_inventory={starcraft_item}, filler_amount={filler_amount}" diff --git a/worlds/sc2/regions.py b/worlds/sc2/regions.py index 4b02d294d1..299fcde3db 100644 --- a/worlds/sc2/regions.py +++ b/worlds/sc2/regions.py @@ -129,7 +129,7 @@ def adjust_mission_pools(world: 'SC2World', pools: SC2MOGenMissionPools): if grant_story_tech == GrantStoryTech.option_grant: # Additional starter mission if player is granted story tech pools.move_mission(SC2Mission.ENEMY_WITHIN, Difficulty.EASY, Difficulty.STARTER) - pools.move_mission(SC2Mission.THE_ESCAPE, Difficulty.MEDIUM, Difficulty.STARTER) + pools.move_mission(SC2Mission.THE_ESCAPE, Difficulty.EASY, Difficulty.STARTER) pools.move_mission(SC2Mission.IN_THE_ENEMY_S_SHADOW, Difficulty.MEDIUM, Difficulty.STARTER) if not war_council_nerfs or grant_story_tech == GrantStoryTech.option_grant: pools.move_mission(SC2Mission.TEMPLAR_S_RETURN, Difficulty.MEDIUM, Difficulty.STARTER) diff --git a/worlds/sc2/rules.py b/worlds/sc2/rules.py index 28a8804e5e..8d55c6a4e5 100644 --- a/worlds/sc2/rules.py +++ b/worlds/sc2/rules.py @@ -1660,11 +1660,11 @@ class SC2Logic: Created mainly for engine of destruction start, but works for other missions with no-build starts. """ return state.has_any(( - item_names.ZEALOT_WHIRLWIND, - item_names.SENTRY_DOUBLE_SHIELD_RECHARGE, - item_names.SLAYER_PHASE_BLINK, - item_names.STALKER_INSTIGATOR_SLAYER_DISINTEGRATING_PARTICLES, - item_names.STALKER_INSTIGATOR_SLAYER_PARTICLE_REFLECTION, + item_names.ZEALOT_WHIRLWIND, + item_names.SENTRY_DOUBLE_SHIELD_RECHARGE, + item_names.SLAYER_PHASE_BLINK, + item_names.STALKER_INSTIGATOR_SLAYER_DISINTEGRATING_PARTICLES, + item_names.STALKER_INSTIGATOR_SLAYER_PARTICLE_REFLECTION, ), self.player) # Mission-specific rules diff --git a/worlds/sc2/test/slow_tests.py b/worlds/sc2/test/slow_tests.py new file mode 100644 index 0000000000..90b6e7a982 --- /dev/null +++ b/worlds/sc2/test/slow_tests.py @@ -0,0 +1,52 @@ +""" +Slow-running tests that are run infrequently. +Run this file explicitly with `python3 -m unittest worlds.sc2.test.slow_tests` +""" +from .test_base import Sc2SetupTestBase + +from Fill import FillError +from .. import mission_tables, options + + +class LargeTests(Sc2SetupTestBase): + def test_any_starter_mission_works(self) -> None: + base_options = { + options.OPTION_NAME[options.SelectedRaces]: list(options.SelectedRaces.valid_keys), + options.OPTION_NAME[options.RequiredTactics]: options.RequiredTactics.option_standard, + options.OPTION_NAME[options.MissionOrder]: options.MissionOrder.option_custom, + options.OPTION_NAME[options.ExcludeOverpoweredItems]: True, + # options.OPTION_NAME[options.ExtraLocations]: options.ExtraLocations.option_disabled, + options.OPTION_NAME[options.VanillaLocations]: options.VanillaLocations.option_disabled, + } + missions_to_check = [ + mission for mission in mission_tables.SC2Mission + if mission.pool == mission_tables.MissionPools.STARTER + ] + failed_missions: list[tuple[mission_tables.SC2Mission, int]] = [] + NUM_ATTEMPTS = 3 + for mission in missions_to_check: + for attempt in range(NUM_ATTEMPTS): + mission_options = base_options | { + options.OPTION_NAME[options.CustomMissionOrder]: { + "Test Campaign": { + "Test Layout": { + "type": "hopscotch", + "size": 25, + "goal": True, + "missions": [ + {"index": 0, "mission_pool": [mission.mission_name]} + ] + } + } + } + } + try: + self.generate_world(mission_options) + self.fill_after_generation() + assert self.multiworld.worlds[1].custom_mission_order.get_starting_missions()[0] == mission + except FillError as ex: + failed_missions.append((mission, self.multiworld.seed)) + if failed_missions: + for failed_mission in failed_missions: + print(failed_mission) + self.assertFalse(failed_missions) diff --git a/worlds/sc2/test/test_base.py b/worlds/sc2/test/test_base.py index f0f778dc79..f6aaaddaba 100644 --- a/worlds/sc2/test/test_base.py +++ b/worlds/sc2/test/test_base.py @@ -1,4 +1,4 @@ -from typing import * +from typing import Any, cast import unittest import random from argparse import Namespace @@ -6,18 +6,11 @@ from BaseClasses import MultiWorld, CollectionState, PlandoOptions from Generate import get_seed_name from worlds import AutoWorld from test.general import gen_steps, call_all +from Fill import distribute_items_restrictive -from test.bases import WorldTestBase from .. import SC2World, SC2Campaign -from .. import client from .. import options -class Sc2TestBase(WorldTestBase): - game = client.SC2Context.game - world: SC2World - player: ClassVar[int] = 1 - skip_long_tests: bool = True - class Sc2SetupTestBase(unittest.TestCase): """ @@ -37,10 +30,11 @@ class Sc2SetupTestBase(unittest.TestCase): PROTOSS_CAMPAIGNS = { 'enabled_campaigns': {SC2Campaign.PROPHECY.campaign_name, SC2Campaign.PROLOGUE.campaign_name, SC2Campaign.LOTV.campaign_name,} } - seed: Optional[int] = None + seed: int | None = None game = SC2World.game player = 1 - def generate_world(self, options: Dict[str, Any]) -> None: + + def generate_world(self, options: dict[str, Any]) -> None: self.multiworld = MultiWorld(1) self.multiworld.game[self.player] = self.game self.multiworld.player_name = {self.player: "Tester"} @@ -63,3 +57,11 @@ class Sc2SetupTestBase(unittest.TestCase): except Exception as ex: ex.add_note(f"Seed: {self.multiworld.seed}") raise + + def fill_after_generation(self) -> None: + assert self.multiworld + try: + distribute_items_restrictive(self.multiworld) + except Exception as ex: + ex.add_note(f"Seed: {self.multiworld.seed}") + raise diff --git a/worlds/sc2/test/test_generation.py b/worlds/sc2/test/test_generation.py index 329cd593e1..708606f7bc 100644 --- a/worlds/sc2/test/test_generation.py +++ b/worlds/sc2/test/test_generation.py @@ -1,20 +1,24 @@ """ Unit tests for world generation """ -from typing import * - +from typing import Any from .test_base import Sc2SetupTestBase -from .. import mission_groups, mission_tables, options, locations, SC2Mission, SC2Campaign, SC2Race, unreleased_items, \ - RequiredTactics +from .. import ( + mission_groups, mission_tables, options, locations, + SC2Mission, SC2Campaign, SC2Race, unreleased_items, + RequiredTactics, +) from ..item import item_groups, item_tables, item_names from .. import get_all_missions, get_random_first_mission -from ..options import EnabledCampaigns, NovaGhostOfAChanceVariant, MissionOrder, ExcludeOverpoweredItems, \ - VanillaItemsOnly, MaximumCampaignSize +from ..options import ( + EnabledCampaigns, NovaGhostOfAChanceVariant, MissionOrder, ExcludeOverpoweredItems, + VanillaItemsOnly, MaximumCampaignSize, +) class TestItemFiltering(Sc2SetupTestBase): - def test_explicit_locks_excludes_interact_and_set_flags(self): + def test_explicit_locks_excludes_interact_and_set_flags(self) -> None: world_options = { **self.ALL_CAMPAIGNS, 'locked_items': { @@ -46,7 +50,7 @@ class TestItemFiltering(Sc2SetupTestBase): regen_biosteel_items = [x for x in itempool if x == item_names.PROGRESSIVE_REGENERATIVE_BIO_STEEL] self.assertEqual(len(regen_biosteel_items), 2) - def test_unexcludes_cancel_out_excludes(self): + def test_unexcludes_cancel_out_excludes(self) -> None: world_options = { 'grant_story_tech': options.GrantStoryTech.option_grant, 'excluded_items': { @@ -121,7 +125,7 @@ class TestItemFiltering(Sc2SetupTestBase): itempool = [item.name for item in self.multiworld.itempool] self.assertNotIn(item_names.MARINE, itempool) - def test_excluding_groups_excludes_all_items_in_group(self): + def test_excluding_groups_excludes_all_items_in_group(self) -> None: world_options = { 'excluded_items': { item_groups.ItemGroupNames.BARRACKS_UNITS.lower(): -1, @@ -133,7 +137,7 @@ class TestItemFiltering(Sc2SetupTestBase): for item_name in item_groups.barracks_units: self.assertNotIn(item_name, itempool) - def test_excluding_mission_groups_excludes_all_missions_in_group(self): + def test_excluding_mission_groups_excludes_all_missions_in_group(self) -> None: world_options = { **self.ZERG_CAMPAIGNS, 'enable_race_swap': options.EnableRaceSwapVariants.option_shuffle_all, @@ -164,7 +168,7 @@ class TestItemFiltering(Sc2SetupTestBase): self.assertNotEqual(item_data.type, item_tables.TerranItemType.Nova_Gear) self.assertNotEqual(item_name, item_names.NOVA_PROGRESSIVE_STEALTH_SUIT_MODULE) - def test_starter_unit_populates_start_inventory(self): + def test_starter_unit_populates_start_inventory(self) -> None: world_options = { 'enabled_campaigns': { SC2Campaign.WOL.campaign_name, @@ -308,7 +312,7 @@ class TestItemFiltering(Sc2SetupTestBase): self.generate_world(world_options) world_items = [(item.name, item_tables.item_table[item.name]) for item in self.multiworld.itempool] self.assertTrue(world_items) - occurrences: Dict[str, int] = {} + occurrences: dict[str, int] = {} for item_name, _ in world_items: if item_name in item_groups.terran_progressive_items: if item_name in item_groups.nova_equipment: @@ -528,7 +532,7 @@ class TestItemFiltering(Sc2SetupTestBase): Orbital command got replaced. The item is still there for backwards compatibility. It shouldn't be generated. """ - world_options = {} + world_options: dict[str, Any] = {} self.generate_world(world_options) itempool = [item.name for item in self.multiworld.itempool] @@ -595,7 +599,7 @@ class TestItemFiltering(Sc2SetupTestBase): self.assertIn(speedrun_location_name, all_location_names) self.assertNotIn(speedrun_location_name, world_location_names) - def test_nco_and_wol_picks_correct_starting_mission(self): + def test_nco_and_wol_picks_correct_starting_mission(self) -> None: world_options = { 'mission_order': MissionOrder.option_vanilla, 'enabled_campaigns': { @@ -606,7 +610,7 @@ class TestItemFiltering(Sc2SetupTestBase): self.generate_world(world_options) self.assertEqual(get_random_first_mission(self.world, self.world.custom_mission_order), mission_tables.SC2Mission.LIBERATION_DAY) - def test_excluding_mission_short_name_excludes_all_variants_of_mission(self): + def test_excluding_mission_short_name_excludes_all_variants_of_mission(self) -> None: world_options = { 'excluded_missions': [ mission_tables.SC2Mission.ZERO_HOUR.mission_name.split(" (")[0] @@ -625,7 +629,7 @@ class TestItemFiltering(Sc2SetupTestBase): self.assertNotIn(mission_tables.SC2Mission.ZERO_HOUR_Z, missions) self.assertNotIn(mission_tables.SC2Mission.ZERO_HOUR_P, missions) - def test_excluding_mission_variant_excludes_just_that_variant(self): + def test_excluding_mission_variant_excludes_just_that_variant(self) -> None: world_options = { 'excluded_missions': [ mission_tables.SC2Mission.ZERO_HOUR.mission_name @@ -644,7 +648,7 @@ class TestItemFiltering(Sc2SetupTestBase): self.assertIn(mission_tables.SC2Mission.ZERO_HOUR_Z, missions) self.assertIn(mission_tables.SC2Mission.ZERO_HOUR_P, missions) - def test_weapon_armor_upgrades(self): + def test_weapon_armor_upgrades(self) -> None: world_options = { # Vanilla WoL with all missions 'mission_order': options.MissionOrder.option_vanilla, @@ -682,7 +686,7 @@ class TestItemFiltering(Sc2SetupTestBase): self.assertGreaterEqual(len(vehicle_weapon_items), 3) self.assertEqual(len(other_bundle_items), 0) - def test_weapon_armor_upgrades_with_bundles(self): + def test_weapon_armor_upgrades_with_bundles(self) -> None: world_options = { # Vanilla WoL with all missions 'mission_order': options.MissionOrder.option_vanilla, @@ -720,7 +724,7 @@ class TestItemFiltering(Sc2SetupTestBase): self.assertGreaterEqual(len(vehicle_upgrade_items), 3) self.assertEqual(len(other_bundle_items), 0) - def test_weapon_armor_upgrades_all_in_air(self): + def test_weapon_armor_upgrades_all_in_air(self) -> None: world_options = { # Vanilla WoL with all missions 'mission_order': options.MissionOrder.option_vanilla, @@ -753,7 +757,7 @@ class TestItemFiltering(Sc2SetupTestBase): self.assertGreaterEqual(len(vehicle_weapon_items), 3) self.assertGreaterEqual(len(ship_weapon_items), 3) - def test_weapon_armor_upgrades_generic_upgrade_missions(self): + def test_weapon_armor_upgrades_generic_upgrade_missions(self) -> None: """ Tests the case when there aren't enough missions in order to get required weapon/armor upgrades for logic requirements. @@ -782,7 +786,7 @@ class TestItemFiltering(Sc2SetupTestBase): # Under standard tactics you need to place L3 upgrades for available unit classes self.assertEqual(len(upgrade_items), 3) - def test_weapon_armor_upgrades_generic_upgrade_missions_no_logic(self): + def test_weapon_armor_upgrades_generic_upgrade_missions_no_logic(self) -> None: """ Tests the case when there aren't enough missions in order to get required weapon/armor upgrades for logic requirements. @@ -813,7 +817,7 @@ class TestItemFiltering(Sc2SetupTestBase): # No logic won't take the fallback to trigger self.assertEqual(len(upgrade_items), 0) - def test_weapon_armor_upgrades_generic_upgrade_missions_no_countermeasure_needed(self): + def test_weapon_armor_upgrades_generic_upgrade_missions_no_countermeasure_needed(self) -> None: world_options = { # Vanilla WoL with all missions 'mission_order': options.MissionOrder.option_vanilla, @@ -837,7 +841,7 @@ class TestItemFiltering(Sc2SetupTestBase): # No additional starting inventory item placement is needed self.assertEqual(len(upgrade_items), 0) - def test_kerrigan_levels_per_mission_triggering_pre_fill(self): + def test_kerrigan_levels_per_mission_triggering_pre_fill(self) -> None: world_options = { **self.ALL_CAMPAIGNS, 'mission_order': options.MissionOrder.option_custom, @@ -878,7 +882,7 @@ class TestItemFiltering(Sc2SetupTestBase): self.assertGreater(len(kerrigan_1_stacks), 0) - def test_kerrigan_levels_per_mission_and_generic_upgrades_both_triggering_pre_fill(self): + def test_kerrigan_levels_per_mission_and_generic_upgrades_both_triggering_pre_fill(self) -> None: world_options = { **self.ALL_CAMPAIGNS, 'mission_order': options.MissionOrder.option_custom, @@ -925,7 +929,7 @@ class TestItemFiltering(Sc2SetupTestBase): self.assertNotIn(item_names.KERRIGAN_LEVELS_70, itempool) self.assertNotIn(item_names.KERRIGAN_LEVELS_70, starting_inventory) - def test_locking_required_items(self): + def test_locking_required_items(self) -> None: world_options = { **self.ALL_CAMPAIGNS, 'mission_order': options.MissionOrder.option_custom, @@ -962,7 +966,7 @@ class TestItemFiltering(Sc2SetupTestBase): self.assertIn(item_names.KERRIGAN_MEND, itempool) - def test_fully_balanced_mission_races(self): + def test_fully_balanced_mission_races(self) -> None: """ Tests whether fully balanced mission race balancing actually is fully balanced. """ @@ -1080,7 +1084,7 @@ class TestItemFiltering(Sc2SetupTestBase): self.generate_world(world_options) itempool = [item.name for item in self.multiworld.itempool] - upgrade_item_counts: Dict[str, int] = {} + upgrade_item_counts: dict[str, int] = {} for item_name in itempool: if item_tables.item_table[item_name].type in ( item_tables.TerranItemType.Upgrade, @@ -1252,7 +1256,7 @@ class TestItemFiltering(Sc2SetupTestBase): self.generate_world(world_options) itempool = [item.name for item in self.multiworld.itempool] - items_to_check: List[str] = unreleased_items + items_to_check: list[str] = unreleased_items for item in items_to_check: self.assertNotIn(item, itempool) @@ -1273,7 +1277,7 @@ class TestItemFiltering(Sc2SetupTestBase): self.generate_world(world_options) itempool = [item.name for item in self.multiworld.itempool] - items_to_check: List[str] = unreleased_items + items_to_check: list[str] = unreleased_items for item in items_to_check: self.assertIn(item, itempool) diff --git a/worlds/sc2/test/test_regions.py b/worlds/sc2/test/test_regions.py index 880a02f973..5d9870d894 100644 --- a/worlds/sc2/test/test_regions.py +++ b/worlds/sc2/test/test_regions.py @@ -1,9 +1,10 @@ import unittest -from .test_base import Sc2TestBase +from .test_base import Sc2SetupTestBase from .. import mission_tables, SC2Campaign from .. import options from ..mission_order.layout_types import Grid + class TestGridsizes(unittest.TestCase): def test_grid_sizes_meet_specs(self): self.assertTupleEqual((1, 2, 0), Grid.get_grid_dimensions(2)) @@ -24,17 +25,17 @@ class TestGridsizes(unittest.TestCase): self.assertTupleEqual((5, 7, 2), Grid.get_grid_dimensions(33)) -class TestGridGeneration(Sc2TestBase): - options = { - "mission_order": options.MissionOrder.option_grid, - "excluded_missions": [mission_tables.SC2Mission.ZERO_HOUR.mission_name,], - "enabled_campaigns": { - SC2Campaign.WOL.campaign_name, - SC2Campaign.PROPHECY.campaign_name, - } - } - +class TestGridGeneration(Sc2SetupTestBase): def test_size_matches_exclusions(self): + world_options = { + options.OPTION_NAME[options.MissionOrder]: options.MissionOrder.option_grid, + options.OPTION_NAME[options.ExcludedMissions]: [mission_tables.SC2Mission.ZERO_HOUR.mission_name], + options.OPTION_NAME[options.EnabledCampaigns]: { + SC2Campaign.WOL.campaign_name, + SC2Campaign.PROPHECY.campaign_name, + } + } + self.generate_world(world_options) self.assertNotIn(mission_tables.SC2Mission.ZERO_HOUR.mission_name, self.multiworld.regions) # WoL has 29 missions. -1 for Zero Hour being excluded, +1 for the automatically-added menu location self.assertEqual(len(self.multiworld.regions), 29) From 74f41e3733b9683d49a70c947926ff2f2a26b633 Mon Sep 17 00:00:00 2001 From: qwint Date: Fri, 27 Mar 2026 18:58:36 -0500 Subject: [PATCH 03/26] Core: Make Generate.main only init logging on __main__ (#6069) --- Generate.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Generate.py b/Generate.py index b510640170..509bf848d0 100644 --- a/Generate.py +++ b/Generate.py @@ -87,7 +87,8 @@ def main(args=None) -> tuple[argparse.Namespace, int]: seed = get_seed(args.seed) - Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level, add_timestamp=args.log_time) + if __name__ == "__main__": + Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level, add_timestamp=args.log_time) random.seed(seed) seed_name = get_seed_name(random) From 645f25a94e411509b5a5bdfad1810706dd66e8bd Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Sun, 29 Mar 2026 12:29:37 -0400 Subject: [PATCH 04/26] setup.py: add rule_builder.cached_world to included list (#6070) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index ebef3880fc..3c40eab59e 100644 --- a/setup.py +++ b/setup.py @@ -657,7 +657,7 @@ cx_Freeze.setup( options={ "build_exe": { "packages": ["worlds", "kivy", "cymem", "websockets", "kivymd"], - "includes": [], + "includes": ["rule_builder.cached_world"], "excludes": ["numpy", "Cython", "PySide2", "PIL", "pandas"], "zip_includes": [], From cf47cc67c0683228a60b81b07ad7c8a96875bdfc Mon Sep 17 00:00:00 2001 From: black-sliver <59490463+black-sliver@users.noreply.github.com> Date: Sun, 29 Mar 2026 17:43:26 +0000 Subject: [PATCH 05/26] Clients: remove datapackage from persistent_storage ... (#6074) ... next time it gets written to. This makes loading peristent_storage faster in the future. --- Utils.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/Utils.py b/Utils.py index 627235f249..cd4af275da 100644 --- a/Utils.py +++ b/Utils.py @@ -345,6 +345,9 @@ def persistent_load() -> Dict[str, Dict[str, Any]]: try: with open(path, "r") as f: storage = unsafe_parse_yaml(f.read()) + if "datapackage" in storage: + del storage["datapackage"] + logging.debug("Removed old datapackage from persistent storage") except Exception as e: logging.debug(f"Could not read store: {e}") if storage is None: @@ -369,11 +372,6 @@ def load_data_package_for_checksum(game: str, checksum: typing.Optional[str]) -> except Exception as e: logging.debug(f"Could not load data package: {e}") - # fall back to old cache - cache = persistent_load().get("datapackage", {}).get("games", {}).get(game, {}) - if cache.get("checksum") == checksum: - return cache - # cache does not match return {} From ffe4c6dd1504166bc96d99237d4177e80c6dc161 Mon Sep 17 00:00:00 2001 From: black-sliver <59490463+black-sliver@users.noreply.github.com> Date: Sun, 29 Mar 2026 17:44:29 +0000 Subject: [PATCH 06/26] Core, Webhost: update and pin dependency versions (#6075) --- WebHostLib/requirements.txt | 24 +++++++++++----------- requirements.txt | 34 ++++++++++++++++---------------- worlds/alttp/requirements.txt | 4 ++-- worlds/factorio/requirements.txt | 2 +- 4 files changed, 32 insertions(+), 32 deletions(-) diff --git a/WebHostLib/requirements.txt b/WebHostLib/requirements.txt index c9a923680a..fd194f2232 100644 --- a/WebHostLib/requirements.txt +++ b/WebHostLib/requirements.txt @@ -1,14 +1,14 @@ -flask>=3.1.1 -werkzeug>=3.1.3 -pony>=0.7.19; python_version <= '3.12' +flask==3.1.3 +werkzeug==3.1.6 +pony==0.7.19; python_version <= '3.12' pony @ git+https://github.com/black-sliver/pony@7feb1221953b7fa4a6735466bf21a8b4d35e33ba#0.7.19; python_version >= '3.13' -waitress>=3.0.2 -Flask-Caching>=2.3.0 +waitress==3.0.2 +Flask-Caching==2.3.1 Flask-Compress==1.18 # pkg_resources can't resolve the "backports.zstd" dependency of >1.18, breaking ModuleUpdate.py -Flask-Limiter>=3.12 -Flask-Cors>=6.0.2 -bokeh>=3.6.3 -markupsafe>=3.0.2 -setproctitle>=1.3.5 -mistune>=3.1.3 -docutils>=0.22.2 +Flask-Limiter==4.1.1 +Flask-Cors==6.0.2 +bokeh==3.8.2 +markupsafe==3.0.3 +setproctitle==1.3.7 +mistune==3.2.0 +docutils==0.22.4 diff --git a/requirements.txt b/requirements.txt index 27bca5c7c2..3a91b16680 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,21 +1,21 @@ -colorama>=0.4.6 -websockets>=13.0.1,<14 -PyYAML>=6.0.3 -jellyfish>=1.2.1 -jinja2>=3.1.6 -schema>=0.7.8 -kivy>=2.3.1 -bsdiff4>=1.2.6 -platformdirs>=4.5.0 -certifi>=2025.11.12 -cython>=3.2.1 -cymem>=2.0.13 -orjson>=3.11.4 -typing_extensions>=4.15.0 -pyshortcuts>=1.9.6 -pathspec>=0.12.1 +colorama==0.4.6 +websockets==13.1 # ,<14 +PyYAML==6.0.3 +jellyfish==1.2.1 +jinja2==3.1.6 +schema==0.7.8 +kivy==2.3.1 +bsdiff4==1.2.6 +platformdirs==4.9.4 +certifi==2026.2.25 +cython==3.2.4 +cymem==2.0.13 +orjson==3.11.7 +typing_extensions==4.15.0 +pyshortcuts==1.9.7 +pathspec==1.0.4 kivymd @ git+https://github.com/kivymd/KivyMD@5ff9d0d kivymd>=2.0.1.dev0 # Legacy world dependencies that custom worlds rely on -Pymem>=1.13.0 +Pymem==1.14.0 diff --git a/worlds/alttp/requirements.txt b/worlds/alttp/requirements.txt index 8a96da2e63..8eccc7e5f3 100644 --- a/worlds/alttp/requirements.txt +++ b/worlds/alttp/requirements.txt @@ -1,2 +1,2 @@ -maseya-z3pr>=1.0.0rc1 -xxtea>=3.0.0 +maseya-z3pr==1.0.0rc1 +xxtea==3.7.0 diff --git a/worlds/factorio/requirements.txt b/worlds/factorio/requirements.txt index 8d68440166..b2b3804a50 100644 --- a/worlds/factorio/requirements.txt +++ b/worlds/factorio/requirements.txt @@ -1 +1 @@ -factorio-rcon-py>=2.1.2 +factorio-rcon-py==2.1.3 From 1705620c4f37e86b8414aa3bf70d4fdd1ed29ded Mon Sep 17 00:00:00 2001 From: Duck <31627079+duckboycool@users.noreply.github.com> Date: Sun, 29 Mar 2026 12:07:55 -0600 Subject: [PATCH 07/26] Launcher: Add konsole to terminal list and rework launch dialog (#5684) * Make component launching indicate if no terminal window, add konsole * Attempt to spell better and remove whitespace * Update terminal priority * Make helper for clearing LD_LIBRARY_PATH * Add handling to linux launch * Hopefully fix setter * Apply suggestions from code review Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com> --------- Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com> --- Launcher.py | 49 +++++++++++++++++++++++++++++-------------------- Utils.py | 25 ++++++++++++++++--------- 2 files changed, 45 insertions(+), 29 deletions(-) diff --git a/Launcher.py b/Launcher.py index cffd96b85d..0e7d4796c4 100644 --- a/Launcher.py +++ b/Launcher.py @@ -29,8 +29,8 @@ if __name__ == "__main__": import settings import Utils -from Utils import (init_logging, is_frozen, is_linux, is_macos, is_windows, local_path, messagebox, open_filename, - user_path) +from Utils import (env_cleared_lib_path, init_logging, is_frozen, is_linux, is_macos, is_windows, local_path, + messagebox, open_filename, user_path) if __name__ == "__main__": init_logging('Launcher') @@ -52,10 +52,7 @@ def open_host_yaml(): webbrowser.open(file) return - env = os.environ - if "LD_LIBRARY_PATH" in env: - env = env.copy() - del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH + env = env_cleared_lib_path() subprocess.Popen([exe, file], env=env) def open_patch(): @@ -106,10 +103,7 @@ def open_folder(folder_path): return if exe: - env = os.environ - if "LD_LIBRARY_PATH" in env: - env = env.copy() - del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH + env = env_cleared_lib_path() subprocess.Popen([exe, folder_path], env=env) else: logging.warning(f"No file browser available to open {folder_path}") @@ -202,22 +196,32 @@ def get_exe(component: str | Component) -> Sequence[str] | None: return [sys.executable, local_path(f"{component.script_name}.py")] if component.script_name else None -def launch(exe, in_terminal=False): +def launch(exe: Sequence[str], in_terminal: bool = False) -> bool: + """Runs the given command/args in `exe` in a new process. + + If `in_terminal` is True, it will attempt to run in a terminal window, + and the return value will indicate whether one was found.""" if in_terminal: if is_windows: # intentionally using a window title with a space so it gets quoted and treated as a title subprocess.Popen(["start", "Running Archipelago", *exe], shell=True) - return + return True elif is_linux: - terminal = which('x-terminal-emulator') or which('gnome-terminal') or which('xterm') + terminal = which("x-terminal-emulator") or which("konsole") or which("gnome-terminal") or which("xterm") if terminal: - subprocess.Popen([terminal, '-e', shlex.join(exe)]) - return + # Clear LD_LIB_PATH during terminal startup, but set it again when running command in case it's needed + ld_lib_path = os.environ.get("LD_LIBRARY_PATH") + lib_path_setter = f"env LD_LIBRARY_PATH={shlex.quote(ld_lib_path)} " if ld_lib_path else "" + env = env_cleared_lib_path() + + subprocess.Popen([terminal, "-e", lib_path_setter + shlex.join(exe)], env=env) + return True elif is_macos: - terminal = [which('open'), '-W', '-a', 'Terminal.app'] + terminal = [which("open"), "-W", "-a", "Terminal.app"] subprocess.Popen([*terminal, *exe]) - return + return True subprocess.Popen(exe) + return False def create_shortcut(button: Any, component: Component) -> None: @@ -406,12 +410,17 @@ def run_gui(launch_components: list[Component], args: Any) -> 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() + open_text = "Opening in a new window..." if button.component.func: + # Note: if we want to draw the Snackbar before running func, func needs to be wrapped in schedule_once button.component.func() else: - launch(get_exe(button.component), button.component.cli) + # if launch returns False, it started the process in background (not in a new terminal) + if not launch(get_exe(button.component), button.component.cli) and button.component.cli: + open_text = "Running in the background..." + + MDSnackbar(MDSnackbarText(text=open_text), y=dp(24), pos_hint={"center_x": 0.5}, + size_hint_x=0.5).open() def _on_drop_file(self, window: Window, filename: bytes, x: int, y: int) -> None: """ When a patch file is dropped into the window, run the associated component. """ diff --git a/Utils.py b/Utils.py index cd4af275da..0210086274 100644 --- a/Utils.py +++ b/Utils.py @@ -22,7 +22,7 @@ from datetime import datetime, timezone from settings import Settings, get_settings from time import sleep -from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union, TypeGuard +from typing import BinaryIO, Coroutine, Mapping, Optional, Set, Dict, Any, Union, TypeGuard from yaml import load, load_all, dump from pathspec import PathSpec, GitIgnoreSpec from typing_extensions import deprecated @@ -236,10 +236,7 @@ def open_file(filename: typing.Union[str, "pathlib.Path"]) -> None: open_command = which("open") if is_macos else (which("xdg-open") or which("gnome-open") or which("kde-open")) assert open_command, "Didn't find program for open_file! Please report this together with system details." - env = os.environ - if "LD_LIBRARY_PATH" in env: - env = env.copy() - del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH + env = env_cleared_lib_path() subprocess.call([open_command, filename], env=env) @@ -756,6 +753,19 @@ def is_kivy_running() -> bool: return False +def env_cleared_lib_path() -> Mapping[str, str]: + """ + Creates a copy of the current environment vars with the LD_LIBRARY_PATH removed if set, as this can interfere when + launching something in a subprocess. + """ + env = os.environ + if "LD_LIBRARY_PATH" in env: + env = env.copy() + del env["LD_LIBRARY_PATH"] + + return env + + def _mp_open_filename(res: "multiprocessing.Queue[typing.Optional[str]]", *args: Any) -> None: if is_kivy_running(): raise RuntimeError("kivy should not be running in multiprocess") @@ -768,10 +778,7 @@ def _mp_save_filename(res: "multiprocessing.Queue[typing.Optional[str]]", *args: res.put(save_filename(*args)) def _run_for_stdout(*args: str): - env = os.environ - if "LD_LIBRARY_PATH" in env: - env = env.copy() - del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH + env = env_cleared_lib_path() return subprocess.run(args, capture_output=True, text=True, env=env).stdout.split("\n", 1)[0] or None From 36cf86f2e8b2c91bc942a97dd5fdc1772707d1c8 Mon Sep 17 00:00:00 2001 From: Sebastian <127998064+SebaCape@users.noreply.github.com> Date: Sun, 29 Mar 2026 15:18:03 -0400 Subject: [PATCH 08/26] Docs: update macOS setup instructions for more specificity on Python version (#6078) --- worlds/generic/docs/mac_en.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/worlds/generic/docs/mac_en.md b/worlds/generic/docs/mac_en.md index 4db48d2abd..eaaeb13c15 100644 --- a/worlds/generic/docs/mac_en.md +++ b/worlds/generic/docs/mac_en.md @@ -2,7 +2,7 @@ Archipelago does not have a compiled release on macOS. However, it is possible to run from source code on macOS. This guide expects you to have some experience with running software from the terminal. ## Prerequisite Software Here is a list of software to install and source code to download. -1. Python 3.11 "universal2" or newer from the [macOS Python downloads page](https://www.python.org/downloads/macos/). +1. Python 3.11.9 "universal2" or newer from the [macOS Python downloads page](https://www.python.org/downloads/macos/). **Python 3.14 is not supported yet.** 2. Xcode from the [macOS App Store](https://apps.apple.com/us/app/xcode/id497799835). 3. The source code from the [Archipelago releases page](https://github.com/ArchipelagoMW/Archipelago/releases). From 5ca50cd8d34d9f42b2f8ad76c5bf64203a643cb4 Mon Sep 17 00:00:00 2001 From: Bryce Wilson Date: Sun, 29 Mar 2026 14:10:16 -0700 Subject: [PATCH 09/26] Pokemon Emerald: Fix Latios KeyError (#6056) --- worlds/pokemon_emerald/__init__.py | 8 ++++++++ worlds/pokemon_emerald/pokemon.py | 4 ++-- worlds/pokemon_emerald/rules.py | 2 +- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/worlds/pokemon_emerald/__init__.py b/worlds/pokemon_emerald/__init__.py index 10abed539f..fb683df90d 100644 --- a/worlds/pokemon_emerald/__init__.py +++ b/worlds/pokemon_emerald/__init__.py @@ -263,6 +263,14 @@ class PokemonEmeraldWorld(World): if self.options.hms == RandomizeHms.option_shuffle: self.options.local_items.value.update(self.item_name_groups["HM"]) + # Manually enable Latios as a dexsanity location if we're doing legendary hunt (which confines Latios to + # the roamer encounter), the player allows Latios as a valid legendary hunt target, and they didn't also + # blacklist Latios to remove its dexsanity location + if self.options.goal == Goal.option_legendary_hunt and self.options.dexsanity \ + and "Latios" in self.options.allowed_legendary_hunt_encounters.value \ + and emerald_data.constants["SPECIES_LATIOS"] not in self.blacklisted_wilds: + self.allowed_dexsanity_species.add(emerald_data.constants["SPECIES_LATIOS"]) + def create_regions(self) -> None: from .regions import create_regions all_regions = create_regions(self) diff --git a/worlds/pokemon_emerald/pokemon.py b/worlds/pokemon_emerald/pokemon.py index 73af6c4658..76285d11da 100644 --- a/worlds/pokemon_emerald/pokemon.py +++ b/worlds/pokemon_emerald/pokemon.py @@ -376,10 +376,10 @@ def randomize_wild_encounters(world: "PokemonEmeraldWorld") -> None: # Actually create the new list of slots and encounter table new_slots: List[int] = [] - if encounter_type in enabled_encounters: - world.allowed_dexsanity_species.update(table.slots) for species_id in table.slots: new_slots.append(species_old_to_new_map[species_id]) + if encounter_type in enabled_encounters: + world.allowed_dexsanity_species.update(new_slots) new_encounters[encounter_type] = EncounterTableData(new_slots, table.address) diff --git a/worlds/pokemon_emerald/rules.py b/worlds/pokemon_emerald/rules.py index eeadb8bea2..30ebf72e4d 100644 --- a/worlds/pokemon_emerald/rules.py +++ b/worlds/pokemon_emerald/rules.py @@ -1559,7 +1559,7 @@ def set_rules(world: "PokemonEmeraldWorld") -> None: # Legendary hunt prevents Latios from being a wild spawn so the roamer # can be tracked, and also guarantees that the roamer is a Latios. if world.options.goal == Goal.option_legendary_hunt and \ - data.constants["SPECIES_LATIOS"] not in world.blacklisted_wilds: + data.constants["SPECIES_LATIOS"] in world.allowed_dexsanity_species: set_rule( get_location(f"Pokedex - Latios"), lambda state: state.has("EVENT_ENCOUNTER_LATIOS", world.player) From 03c9d0717bb1d8a9da416416fc5225beeda04c3c Mon Sep 17 00:00:00 2001 From: Mysteryem Date: Sun, 29 Mar 2026 22:12:25 +0100 Subject: [PATCH 10/26] Muse Dash: Fix nondeterministic generation with include_songs (#6040) The include_songs option is an OptionSet, whose value is a set, but was being iterated to produce self.included_songs. Sets are unordered and may have a different iteration order each time a python process is run. This meant that the order of the elements in self.included_songs could differ even when generating with a fixed seed. This caused nondeterministic generation with the same seed because create_song_pool() deterministically randomly picks songs from self.included_songs, which could be in a different order each time, so different songs could be picked. --- worlds/musedash/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/worlds/musedash/__init__.py b/worlds/musedash/__init__.py index 239d640e68..55767cf04b 100644 --- a/worlds/musedash/__init__.py +++ b/worlds/musedash/__init__.py @@ -124,7 +124,8 @@ class MuseDashWorld(World): self.starting_songs = [s for s in start_items if s in song_items] self.starting_songs = self.md_collection.filter_songs_to_dlc(self.starting_songs, dlc_songs) - self.included_songs = [s for s in include_songs if s in song_items and s not in self.starting_songs] + # Sort first for deterministic iteration order. + self.included_songs = [s for s in sorted(include_songs) if s in song_items and s not in self.starting_songs] self.included_songs = self.md_collection.filter_songs_to_dlc(self.included_songs, dlc_songs) # Making sure songs chosen for goal are allowed by DLC and remove the chosen from being added to the pool. From 393ed51203c19b228bbccaa82826be97781e8c89 Mon Sep 17 00:00:00 2001 From: Flit <8645405+FlitPix@users.noreply.github.com> Date: Sun, 29 Mar 2026 17:16:34 -0400 Subject: [PATCH 11/26] Messenger: Require Wingsuit to traverse Dark Cave (#6059) --- worlds/messenger/rules.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/worlds/messenger/rules.py b/worlds/messenger/rules.py index 7f17232cfb..bc1fc6aa99 100644 --- a/worlds/messenger/rules.py +++ b/worlds/messenger/rules.py @@ -192,7 +192,7 @@ class MessengerRules: or (self.has_dart(state) and self.has_wingsuit(state)), # Dark Cave "Dark Cave - Right -> Dark Cave - Left": - lambda state: state.has("Candle", self.player) and self.has_dart(state), + lambda state: state.has("Candle", self.player) and self.has_dart(state) and self.has_wingsuit(state), # Riviere Turquoise "Riviere Turquoise - Waterfall Shop -> Riviere Turquoise - Flower Flight Checkpoint": lambda state: self.has_dart(state) or ( From 2d58e7953c0ba1092281f44b49dd5c55a385b9ee Mon Sep 17 00:00:00 2001 From: agilbert1412 Date: Sun, 29 Mar 2026 17:20:00 -0400 Subject: [PATCH 12/26] Stardew valley: Four small fixes (#6055) * - Fixed the Dr Seuss Bundle asking for tigerseye (mineral) instead of tiger trout (fish) * - Made blue grass starter more consistent * - Fragments of the past does not rely on ginger island * - Removed legacy hard coded strange bun recipe that messed with chefsanity logic --- worlds/stardew_valley/data/bundles_data/meme_bundles.py | 2 +- worlds/stardew_valley/data/locations.csv | 4 ++-- worlds/stardew_valley/logic/logic.py | 1 - 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/worlds/stardew_valley/data/bundles_data/meme_bundles.py b/worlds/stardew_valley/data/bundles_data/meme_bundles.py index c3abdca129..a9b7f8c7da 100644 --- a/worlds/stardew_valley/data/bundles_data/meme_bundles.py +++ b/worlds/stardew_valley/data/bundles_data/meme_bundles.py @@ -257,7 +257,7 @@ algorerhythm_bundle = BundleTemplate(CCRoom.bulletin_board, MemeBundleName.algor red_fish_items = [red_mullet, red_snapper, lava_eel, crimsonfish] blue_fish_items = [anchovy, tuna, sardine, bream, squid, ice_pip, albacore, blue_discus, midnight_squid, spook_fish, glacierfish] other_fish = [pufferfish, largemouth_bass, smallmouth_bass, rainbow_trout, walleye, perch, carp, catfish, pike, sunfish, herring, eel, octopus, sea_cucumber, - super_cucumber, ghostfish, stonefish, sandfish, scorpion_carp, flounder, midnight_carp, tigerseye, bullhead, tilapia, chub, dorado, shad, + super_cucumber, ghostfish, stonefish, sandfish, scorpion_carp, flounder, midnight_carp, bullhead, tilapia, chub, dorado, shad, tiger_trout, lingcod, halibut, slimejack, stingray, goby, blobfish, angler, legend, mutant_carp] dr_seuss_items = [other_fish, [fish.as_amount(2) for fish in other_fish], red_fish_items, blue_fish_items] dr_seuss_bundle = FixedPriceDeepBundleTemplate(CCRoom.crafts_room, MemeBundleName.dr_seuss, dr_seuss_items, 4, 4) diff --git a/worlds/stardew_valley/data/locations.csv b/worlds/stardew_valley/data/locations.csv index 9c6cffad51..eea7c61150 100644 --- a/worlds/stardew_valley/data/locations.csv +++ b/worlds/stardew_valley/data/locations.csv @@ -1182,7 +1182,7 @@ id,region,name,tags,content_packs 2104,Fishing,Biome Balance,SPECIAL_ORDER_BOARD, 2105,Haley's House,Rock Rejuvenation,SPECIAL_ORDER_BOARD, 2106,Alex's House,Gifts for George,SPECIAL_ORDER_BOARD, -2107,Museum,Fragments of the past,"GINGER_ISLAND,SPECIAL_ORDER_BOARD", +2107,Museum,Fragments of the past,"SPECIAL_ORDER_BOARD", 2108,Saloon,Gus' Famous Omelet,SPECIAL_ORDER_BOARD, 2109,Farm,Crop Order,SPECIAL_ORDER_BOARD, 2110,Railroad,Community Cleanup,SPECIAL_ORDER_BOARD, @@ -2227,7 +2227,7 @@ id,region,name,tags,content_packs 3530,Farm,Craft Cookout Kit,"CRAFTSANITY,CRAFTSANITY_CRAFT", 3531,Farm,Craft Fish Smoker,"CRAFTSANITY,CRAFTSANITY_CRAFT", 3532,Farm,Craft Dehydrator,"CRAFTSANITY,CRAFTSANITY_CRAFT", -3533,Farm,Craft Blue Grass Starter,"CRAFTSANITY,CRAFTSANITY_CRAFT,GINGER_ISLAND", +3533,Farm,Craft Blue Grass Starter,"CRAFTSANITY,CRAFTSANITY_CRAFT,GINGER_ISLAND,REQUIRES_QI_ORDERS", 3534,Farm,Craft Mystic Tree Seed,"CRAFTSANITY,CRAFTSANITY_CRAFT,REQUIRES_MASTERIES", 3535,Farm,Craft Sonar Bobber,"CRAFTSANITY,CRAFTSANITY_CRAFT", 3536,Farm,Craft Challenge Bait,"CRAFTSANITY,CRAFTSANITY_CRAFT,REQUIRES_MASTERIES", diff --git a/worlds/stardew_valley/logic/logic.py b/worlds/stardew_valley/logic/logic.py index 37260d1494..b72c4fc662 100644 --- a/worlds/stardew_valley/logic/logic.py +++ b/worlds/stardew_valley/logic/logic.py @@ -297,7 +297,6 @@ class StardewLogic(ReceivedLogicMixin, HasLogicMixin, RegionLogicMixin, Travelin Material.stone: self.ability.can_mine_stone(), Material.wood: self.ability.can_chop_trees(), Meal.ice_cream: (self.season.has(Season.summer) & self.money.can_spend_at(Region.town, 250)) | self.money.can_spend_at(Region.oasis, 240), - Meal.strange_bun: self.relationship.has_hearts(NPC.shane, 7) & self.has(Ingredient.wheat_flour) & self.has(Fish.periwinkle) & self.has(ArtisanGood.void_mayonnaise), MetalBar.copper: self.can_smelt(Ore.copper), MetalBar.gold: self.can_smelt(Ore.gold), MetalBar.iridium: self.can_smelt(Ore.iridium), From a1ed8042674a5d0714391615cbdbe129fa8a881c Mon Sep 17 00:00:00 2001 From: Noa Aarts Date: Sun, 29 Mar 2026 23:20:24 +0200 Subject: [PATCH 13/26] Stardew Valley: trimmed lucky purple shorts need gold to make (#6034) The current logic only requires the shorts and a sewing machine, but a gold bar is also necessary --- worlds/stardew_valley/logic/logic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/worlds/stardew_valley/logic/logic.py b/worlds/stardew_valley/logic/logic.py index b72c4fc662..47b51ce7a6 100644 --- a/worlds/stardew_valley/logic/logic.py +++ b/worlds/stardew_valley/logic/logic.py @@ -312,7 +312,7 @@ class StardewLogic(ReceivedLogicMixin, HasLogicMixin, RegionLogicMixin, Travelin RetainingSoil.basic: self.money.can_spend_at(Region.pierre_store, 100), RetainingSoil.quality: self.time.has_year_two & self.money.can_spend_at(Region.pierre_store, 150), SpecialItem.lucky_purple_shorts: self.special_items.has_purple_shorts(), - SpecialItem.trimmed_purple_shorts: self.has(SpecialItem.lucky_purple_shorts) & self.has(Machine.sewing_machine), + SpecialItem.trimmed_purple_shorts: self.has(SpecialItem.lucky_purple_shorts) & self.has(MetalBar.gold) & self.has(Machine.sewing_machine), SpecialItem.far_away_stone: self.special_items.has_far_away_stone(), SpecialItem.solid_gold_lewis: self.special_items.has_solid_gold_lewis(), SpecialItem.advanced_tv_remote: self.special_items.has_advanced_tv_remote(), From 139856a5731752bfdac22a5445422fb50d194b8d Mon Sep 17 00:00:00 2001 From: agilbert1412 Date: Sun, 29 Mar 2026 17:21:29 -0400 Subject: [PATCH 14/26] Stardew Valley: Fixed an issue where some specific option combinations could create more items than locations (#6012) * - Improved the dynamic locations count algorithm to take into account the nature of various heavy settings in both directions * - Fixes from Code Review * - We're only testing for sunday locations, might as well only take sunday locations in the list to test * - One more slight optimization * - Added consideration for bundles per room in filler locations counting * - Registered some more IDs to handle items up to 10 --- worlds/stardew_valley/data/locations.csv | 14 +++++ worlds/stardew_valley/locations.py | 46 +++++++++++--- .../test/TestNumberLocations.py | 15 +++-- .../test/long/TestNumberLocationsLong.py | 62 +++++++++++++++++++ worlds/stardew_valley/test/options/presets.py | 45 ++++++++++++++ 5 files changed, 170 insertions(+), 12 deletions(-) create mode 100644 worlds/stardew_valley/test/long/TestNumberLocationsLong.py diff --git a/worlds/stardew_valley/data/locations.csv b/worlds/stardew_valley/data/locations.csv index eea7c61150..8c4a521d77 100644 --- a/worlds/stardew_valley/data/locations.csv +++ b/worlds/stardew_valley/data/locations.csv @@ -438,6 +438,8 @@ id,region,name,tags,content_packs 906,Traveling Cart Sunday,Traveling Merchant Sunday Item 6,"TRAVELING_MERCHANT", 907,Traveling Cart Sunday,Traveling Merchant Sunday Item 7,"TRAVELING_MERCHANT", 908,Traveling Cart Sunday,Traveling Merchant Sunday Item 8,"TRAVELING_MERCHANT", +909,Traveling Cart Sunday,Traveling Merchant Sunday Item 9,"TRAVELING_MERCHANT", +910,Traveling Cart Sunday,Traveling Merchant Sunday Item 10,"TRAVELING_MERCHANT", 911,Traveling Cart Monday,Traveling Merchant Monday Item 1,"MANDATORY,TRAVELING_MERCHANT", 912,Traveling Cart Monday,Traveling Merchant Monday Item 2,"TRAVELING_MERCHANT", 913,Traveling Cart Monday,Traveling Merchant Monday Item 3,"TRAVELING_MERCHANT", @@ -446,6 +448,8 @@ id,region,name,tags,content_packs 916,Traveling Cart Monday,Traveling Merchant Monday Item 6,"TRAVELING_MERCHANT", 917,Traveling Cart Monday,Traveling Merchant Monday Item 7,"TRAVELING_MERCHANT", 918,Traveling Cart Monday,Traveling Merchant Monday Item 8,"TRAVELING_MERCHANT", +919,Traveling Cart Monday,Traveling Merchant Monday Item 9,"TRAVELING_MERCHANT", +920,Traveling Cart Monday,Traveling Merchant Monday Item 10,"TRAVELING_MERCHANT", 921,Traveling Cart Tuesday,Traveling Merchant Tuesday Item 1,"MANDATORY,TRAVELING_MERCHANT", 922,Traveling Cart Tuesday,Traveling Merchant Tuesday Item 2,"TRAVELING_MERCHANT", 923,Traveling Cart Tuesday,Traveling Merchant Tuesday Item 3,"TRAVELING_MERCHANT", @@ -454,6 +458,8 @@ id,region,name,tags,content_packs 926,Traveling Cart Tuesday,Traveling Merchant Tuesday Item 6,"TRAVELING_MERCHANT", 927,Traveling Cart Tuesday,Traveling Merchant Tuesday Item 7,"TRAVELING_MERCHANT", 928,Traveling Cart Tuesday,Traveling Merchant Tuesday Item 8,"TRAVELING_MERCHANT", +929,Traveling Cart Tuesday,Traveling Merchant Tuesday Item 9,"TRAVELING_MERCHANT", +930,Traveling Cart Tuesday,Traveling Merchant Tuesday Item 10,"TRAVELING_MERCHANT", 931,Traveling Cart Wednesday,Traveling Merchant Wednesday Item 1,"MANDATORY,TRAVELING_MERCHANT", 932,Traveling Cart Wednesday,Traveling Merchant Wednesday Item 2,"TRAVELING_MERCHANT", 933,Traveling Cart Wednesday,Traveling Merchant Wednesday Item 3,"TRAVELING_MERCHANT", @@ -462,6 +468,8 @@ id,region,name,tags,content_packs 936,Traveling Cart Wednesday,Traveling Merchant Wednesday Item 6,"TRAVELING_MERCHANT", 937,Traveling Cart Wednesday,Traveling Merchant Wednesday Item 7,"TRAVELING_MERCHANT", 938,Traveling Cart Wednesday,Traveling Merchant Wednesday Item 8,"TRAVELING_MERCHANT", +939,Traveling Cart Wednesday,Traveling Merchant Wednesday Item 9,"TRAVELING_MERCHANT", +940,Traveling Cart Wednesday,Traveling Merchant Wednesday Item 10,"TRAVELING_MERCHANT", 941,Traveling Cart Thursday,Traveling Merchant Thursday Item 1,"MANDATORY,TRAVELING_MERCHANT", 942,Traveling Cart Thursday,Traveling Merchant Thursday Item 2,"TRAVELING_MERCHANT", 943,Traveling Cart Thursday,Traveling Merchant Thursday Item 3,"TRAVELING_MERCHANT", @@ -470,6 +478,8 @@ id,region,name,tags,content_packs 946,Traveling Cart Thursday,Traveling Merchant Thursday Item 6,"TRAVELING_MERCHANT", 947,Traveling Cart Thursday,Traveling Merchant Thursday Item 7,"TRAVELING_MERCHANT", 948,Traveling Cart Thursday,Traveling Merchant Thursday Item 8,"TRAVELING_MERCHANT", +949,Traveling Cart Thursday,Traveling Merchant Thursday Item 9,"TRAVELING_MERCHANT", +950,Traveling Cart Thursday,Traveling Merchant Thursday Item 10,"TRAVELING_MERCHANT", 951,Traveling Cart Friday,Traveling Merchant Friday Item 1,"MANDATORY,TRAVELING_MERCHANT", 952,Traveling Cart Friday,Traveling Merchant Friday Item 2,"TRAVELING_MERCHANT", 953,Traveling Cart Friday,Traveling Merchant Friday Item 3,"TRAVELING_MERCHANT", @@ -478,6 +488,8 @@ id,region,name,tags,content_packs 956,Traveling Cart Friday,Traveling Merchant Friday Item 6,"TRAVELING_MERCHANT", 957,Traveling Cart Friday,Traveling Merchant Friday Item 7,"TRAVELING_MERCHANT", 958,Traveling Cart Friday,Traveling Merchant Friday Item 8,"TRAVELING_MERCHANT", +959,Traveling Cart Friday,Traveling Merchant Friday Item 9,"TRAVELING_MERCHANT", +960,Traveling Cart Friday,Traveling Merchant Friday Item 10,"TRAVELING_MERCHANT", 961,Traveling Cart Saturday,Traveling Merchant Saturday Item 1,"MANDATORY,TRAVELING_MERCHANT", 962,Traveling Cart Saturday,Traveling Merchant Saturday Item 2,"TRAVELING_MERCHANT", 963,Traveling Cart Saturday,Traveling Merchant Saturday Item 3,"TRAVELING_MERCHANT", @@ -486,6 +498,8 @@ id,region,name,tags,content_packs 966,Traveling Cart Saturday,Traveling Merchant Saturday Item 6,"TRAVELING_MERCHANT", 967,Traveling Cart Saturday,Traveling Merchant Saturday Item 7,"TRAVELING_MERCHANT", 968,Traveling Cart Saturday,Traveling Merchant Saturday Item 8,"TRAVELING_MERCHANT", +969,Traveling Cart Saturday,Traveling Merchant Saturday Item 9,"TRAVELING_MERCHANT", +970,Traveling Cart Saturday,Traveling Merchant Saturday Item 10,"TRAVELING_MERCHANT", 1001,Fishing,Fishsanity: Carp,FISHSANITY, 1002,Fishing,Fishsanity: Herring,FISHSANITY, 1003,Fishing,Fishsanity: Smallmouth Bass,FISHSANITY, diff --git a/worlds/stardew_valley/locations.py b/worlds/stardew_valley/locations.py index 613698ac1b..a817022e3d 100644 --- a/worlds/stardew_valley/locations.py +++ b/worlds/stardew_valley/locations.py @@ -1,6 +1,7 @@ import csv import enum import logging +import math from dataclasses import dataclass from random import Random from typing import Optional, Dict, Protocol, List, Iterable @@ -16,7 +17,7 @@ from .mods.mod_data import ModNames from .options import ArcadeMachineLocations, SpecialOrderLocations, Museumsanity, \ FestivalLocations, ElevatorProgression, BackpackProgression, FarmType from .options import StardewValleyOptions, Craftsanity, Chefsanity, Cooksanity, Shipsanity, Monstersanity -from .options.options import BackpackSize, Moviesanity, Eatsanity, IncludeEndgameLocations, Friendsanity +from .options.options import BackpackSize, Moviesanity, Eatsanity, IncludeEndgameLocations, Friendsanity, Fishsanity, SkillProgression, Cropsanity from .strings.ap_names.ap_option_names import WalnutsanityOptionName, SecretsanityOptionName, EatsanityOptionName, ChefsanityOptionName, StartWithoutOptionName from .strings.backpack_tiers import Backpack from .strings.goal_names import Goal @@ -665,19 +666,48 @@ def extend_endgame_locations(randomized_locations: List[LocationData], options: def extend_filler_locations(randomized_locations: List[LocationData], options: StardewValleyOptions, content: StardewContent): days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] - i = 1 - while len(randomized_locations) < 90: - location_name = f"Traveling Merchant Sunday Item {i}" - while any(location.name == location_name for location in randomized_locations): - i += 1 - location_name = f"Traveling Merchant Sunday Item {i}" + number_locations_to_add_per_day = 0 + min_number_locations = 90 # Under 90 locations we can run out of rooms for the mandatory core items + if len(randomized_locations) < min_number_locations: + number_locations_to_add = min_number_locations - len(randomized_locations) + number_locations_to_add_per_day += math.ceil(number_locations_to_add / 7) + + # These settings generate a lot of empty locations, so they can absorb a lot of items + filler_heavy_settings = [options.fishsanity != Fishsanity.option_none, + options.shipsanity != Shipsanity.option_none, + options.cooksanity != Cooksanity.option_none, + options.craftsanity != Craftsanity.option_none, + len(options.eatsanity.value) > 0, + options.museumsanity == Museumsanity.option_all, + options.quest_locations.value >= 0, + options.bundle_per_room >= 2] + # These settings generate orphan items and can cause too many items, if enabled without a complementary of the filler heavy settings + orphan_settings = [len(options.chefsanity.value) > 0, + options.friendsanity != Friendsanity.option_none, + options.skill_progression == SkillProgression.option_progressive_with_masteries, + options.cropsanity != Cropsanity.option_disabled, + len(options.start_without.value) > 0, + options.bundle_per_room <= -1, + options.bundle_per_room <= -2] + + enabled_filler_heavy_settings = len([val for val in filler_heavy_settings if val]) + enabled_orphan_settings = len([val for val in orphan_settings if val]) + if enabled_orphan_settings > enabled_filler_heavy_settings: + number_locations_to_add_per_day += enabled_orphan_settings - enabled_filler_heavy_settings + + if number_locations_to_add_per_day <= 0: + return + + existing_traveling_merchant_locations = [location.name for location in randomized_locations if location.name.startswith("Traveling Merchant Sunday Item ")] + start_num_to_add = len(existing_traveling_merchant_locations) + 1 + + for i in range(start_num_to_add, start_num_to_add+number_locations_to_add_per_day): logger.debug(f"Player too few locations, adding Traveling Merchant Items #{i}") for day in days: location_name = f"Traveling Merchant {day} Item {i}" randomized_locations.append(location_table[location_name]) - def create_locations(location_collector: StardewLocationCollector, bundle_rooms: List[BundleRoom], trash_bear_requests: Dict[str, List[str]], diff --git a/worlds/stardew_valley/test/TestNumberLocations.py b/worlds/stardew_valley/test/TestNumberLocations.py index b21488733b..50b158d282 100644 --- a/worlds/stardew_valley/test/TestNumberLocations.py +++ b/worlds/stardew_valley/test/TestNumberLocations.py @@ -7,6 +7,13 @@ from ..items import Group, item_table from ..items.item_data import FILLER_GROUPS +def get_real_item_count(multiworld): + number_items = len([item for item in multiworld.itempool + if all(filler_group not in item_table[item.name].groups for filler_group in FILLER_GROUPS) and Group.TRAP not in item_table[ + item.name].groups and (item.classification & ItemClassification.progression)]) + return number_items + + class TestLocationGeneration(SVTestBase): def test_all_location_created_are_in_location_table(self): @@ -20,8 +27,7 @@ class TestMinLocationAndMaxItem(SVTestBase): def test_minimal_location_maximal_items_still_valid(self): valid_locations = self.get_real_locations() number_locations = len(valid_locations) - number_items = len([item for item in self.multiworld.itempool - if all(filler_group not in item_table[item.name].groups for filler_group in FILLER_GROUPS) and Group.TRAP not in item_table[item.name].groups]) + number_items = get_real_item_count(self.multiworld) print(f"Stardew Valley - Minimum Locations: {number_locations}, Maximum Items: {number_items} [ISLAND EXCLUDED]") self.assertGreaterEqual(number_locations, number_items) @@ -32,8 +38,7 @@ class TestMinLocationAndMaxItemWithIsland(SVTestBase): def test_minimal_location_maximal_items_with_island_still_valid(self): valid_locations = self.get_real_locations() number_locations = len(valid_locations) - number_items = len([item for item in self.multiworld.itempool - if all(filler_group not in item_table[item.name].groups for filler_group in FILLER_GROUPS) and Group.TRAP not in item_table[item.name].groups and (item.classification & ItemClassification.progression)]) + number_items = get_real_item_count(self.multiworld) print(f"Stardew Valley - Minimum Locations: {number_locations}, Maximum Items: {number_items} [ISLAND INCLUDED]") self.assertGreaterEqual(number_locations, number_items) @@ -99,3 +104,5 @@ class TestAllSanityWithModsSettingsHasAllExpectedLocations(SVTestBase): f"\n\tPlease update test_allsanity_with_mods_has_at_least_locations" f"\n\t\tExpected: {expected_locations}" f"\n\t\tActual: {number_locations}") + + diff --git a/worlds/stardew_valley/test/long/TestNumberLocationsLong.py b/worlds/stardew_valley/test/long/TestNumberLocationsLong.py new file mode 100644 index 0000000000..9a46547aca --- /dev/null +++ b/worlds/stardew_valley/test/long/TestNumberLocationsLong.py @@ -0,0 +1,62 @@ +import unittest + +from BaseClasses import ItemClassification +from ..assertion import get_all_location_names +from ..bases import skip_long_tests, SVTestCase, solo_multiworld +from ..options.presets import setting_mins_and_maxes, allsanity_no_mods_7_x_x, get_minsanity_options, default_7_x_x +from ...items import Group, item_table +from ...items.item_data import FILLER_GROUPS + +if skip_long_tests(): + raise unittest.SkipTest("Long tests disabled") + + +def get_real_item_count(multiworld): + number_items = len([item for item in multiworld.itempool + if all(filler_group not in item_table[item.name].groups for filler_group in FILLER_GROUPS) and Group.TRAP not in item_table[ + item.name].groups and (item.classification & ItemClassification.progression)]) + return number_items + + +class TestCountsPerSetting(SVTestCase): + + def test_items_locations_counts_per_setting_with_ginger_island(self): + option_mins_and_maxes = setting_mins_and_maxes() + + for name in option_mins_and_maxes: + values = option_mins_and_maxes[name] + if not isinstance(values, list): + continue + with self.subTest(f"{name}"): + highest_variance_items = -1 + highest_variance_locations = -1 + for preset in [allsanity_no_mods_7_x_x, default_7_x_x, get_minsanity_options]: + lowest_items = 9999 + lowest_locations = 9999 + highest_items = -1 + highest_locations = -1 + for value in values: + world_options = preset() + world_options[name] = value + with solo_multiworld(world_options, world_caching=False) as (multiworld, _): + num_locations = len([loc for loc in get_all_location_names(multiworld) if not loc.startswith("Traveling Merchant")]) + num_items = get_real_item_count(multiworld) + if num_items > highest_items: + highest_items = num_items + if num_items < lowest_items: + lowest_items = num_items + if num_locations > highest_locations: + highest_locations = num_locations + if num_locations < lowest_locations: + lowest_locations = num_locations + + variance_items = highest_items - lowest_items + variance_locations = highest_locations - lowest_locations + if variance_locations > highest_variance_locations: + highest_variance_locations = variance_locations + if variance_items > highest_variance_items: + highest_variance_items = variance_items + if highest_variance_locations > highest_variance_items: + print(f"Options `{name}` can create up to {highest_variance_locations - highest_variance_items} filler ({highest_variance_locations} locations and up to {highest_variance_items} items)") + if highest_variance_locations < highest_variance_items: + print(f"Options `{name}` can create up to {highest_variance_items - highest_variance_locations} orphan ({highest_variance_locations} locations and up to {highest_variance_items} items)") \ No newline at end of file diff --git a/worlds/stardew_valley/test/options/presets.py b/worlds/stardew_valley/test/options/presets.py index 92aab191de..71ad32bb20 100644 --- a/worlds/stardew_valley/test/options/presets.py +++ b/worlds/stardew_valley/test/options/presets.py @@ -292,3 +292,48 @@ 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 + + +def setting_mins_and_maxes(): + low_orphan_options = { + options.ArcadeMachineLocations.internal_name: [options.ArcadeMachineLocations.option_disabled, options.ArcadeMachineLocations.option_full_shuffling], + options.BackpackProgression.internal_name: [options.BackpackProgression.option_vanilla, options.BackpackProgression.option_progressive], + options.BackpackSize.internal_name: [options.BackpackSize.option_1, options.BackpackSize.option_12], + options.Booksanity.internal_name: [options.Booksanity.option_none, options.Booksanity.option_power_skill, options.Booksanity.option_power, options.Booksanity.option_all], + options.BuildingProgression.internal_name: options.BuildingProgression.option_vanilla_cheap, + options.BundlePerRoom.internal_name: [options.BundlePerRoom.option_two_fewer, options.BundlePerRoom.option_four_extra], + options.BundlePrice.internal_name: options.BundlePrice.option_normal, + options.BundleRandomization.internal_name: options.BundleRandomization.option_remixed, + options.Chefsanity.internal_name: [options.Chefsanity.preset_none, options.Chefsanity.preset_all], + options.Cooksanity.internal_name: [options.Cooksanity.option_none, options.Cooksanity.option_all], + options.Craftsanity.internal_name: [options.Craftsanity.option_none, options.Craftsanity.option_all], + options.Cropsanity.internal_name: [options.Cropsanity.option_disabled, options.Cropsanity.option_enabled], + options.Eatsanity.internal_name: [options.Eatsanity.preset_none, options.Eatsanity.preset_all], + options.ElevatorProgression.internal_name: [options.ElevatorProgression.option_vanilla, options.ElevatorProgression.option_progressive], + options.EnabledFillerBuffs.internal_name: options.EnabledFillerBuffs.preset_all, + options.EntranceRandomization.internal_name: options.EntranceRandomization.option_disabled, + options.ExcludeGingerIsland.internal_name: [options.ExcludeGingerIsland.option_false, options.ExcludeGingerIsland.option_true], + options.FarmType.internal_name: [options.FarmType.option_standard, options.FarmType.option_meadowlands], + options.FestivalLocations.internal_name: [options.FestivalLocations.option_disabled, options.FestivalLocations.option_hard], + options.Fishsanity.internal_name: [options.Fishsanity.option_none, options.Fishsanity.option_all], + options.Friendsanity.internal_name: [options.Friendsanity.option_none, options.Friendsanity.option_all_with_marriage], + options.FriendsanityHeartSize.internal_name: [1, 8], + options.Goal.internal_name: options.Goal.option_allsanity, + options.IncludeEndgameLocations.internal_name: [options.IncludeEndgameLocations.option_false, options.IncludeEndgameLocations.option_true], + options.Mods.internal_name: frozenset(), + options.Monstersanity.internal_name: [options.Monstersanity.option_none, options.Monstersanity.option_one_per_monster], + options.Moviesanity.internal_name: [options.Moviesanity.option_none, options.Moviesanity.option_all_movies_and_all_loved_snacks], + options.Museumsanity.internal_name: [options.Museumsanity.option_none, options.Museumsanity.option_all], + options.NumberOfMovementBuffs.internal_name: [0, 12], + options.QuestLocations.internal_name: [-1, 56], + options.SeasonRandomization.internal_name: [options.SeasonRandomization.option_disabled, options.SeasonRandomization.option_randomized_not_winter], + options.Secretsanity.internal_name: [options.Secretsanity.preset_none, options.Secretsanity.preset_all], + options.Shipsanity.internal_name: [options.Shipsanity.option_none, options.Shipsanity.option_everything], + options.SkillProgression.internal_name: [options.SkillProgression.option_vanilla, options.SkillProgression.option_progressive_with_masteries], + options.SpecialOrderLocations.internal_name: [options.SpecialOrderLocations.option_vanilla, options.SpecialOrderLocations.option_board_qi], + options.StartWithout.internal_name: [options.StartWithout.preset_none, options.StartWithout.preset_all], + options.ToolProgression.internal_name: [options.ToolProgression.option_vanilla, options.ToolProgression.option_progressive], + options.TrapDifficulty.internal_name: options.TrapDifficulty.option_medium, + options.Walnutsanity.internal_name: [options.Walnutsanity.preset_none, options.Walnutsanity.preset_all], + } + return low_orphan_options From 773f3c4f089a60682c4791eff7f1f95d1bb5e055 Mon Sep 17 00:00:00 2001 From: Alchav <59858495+Alchav@users.noreply.github.com> Date: Sun, 29 Mar 2026 17:25:46 -0400 Subject: [PATCH 15/26] Super Mario Land 2: Fix Space Zone 2 Logic (#6025) --- worlds/marioland2/logic.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/worlds/marioland2/logic.py b/worlds/marioland2/logic.py index 54685f91a0..4ccb2eb4c9 100644 --- a/worlds/marioland2/logic.py +++ b/worlds/marioland2/logic.py @@ -478,7 +478,7 @@ def space_zone_2_boss(state, player): def space_zone_2_coins(state, player, coins): auto_scroll = is_auto_scroll(state, player, "Space Zone 2") - reachable_coins = 12 + reachable_coins = 9 if state.has_any(["Mushroom", "Fire Flower", "Carrot", "Space Physics"], player): reachable_coins += 15 if state.has("Space Physics", player) or not auto_scroll: @@ -487,7 +487,7 @@ def space_zone_2_coins(state, player, coins): state.has("Mushroom", player) and state.has_any(["Fire Flower", "Carrot"], player))): reachable_coins += 3 if state.has("Space Physics", player): - reachable_coins += 79 + reachable_coins += 82 if not auto_scroll: reachable_coins += 21 return coins <= reachable_coins From a7a7879df4e61bdb5c061ce72b3c7d60451cef9a Mon Sep 17 00:00:00 2001 From: XxDERProjectxX <114278502+XxDERProjectxX@users.noreply.github.com> Date: Sun, 29 Mar 2026 16:34:21 -0500 Subject: [PATCH 16/26] Satisfactory: bug fix in __init__.py (#5930) Solved indentation error to return to intended functionality --- worlds/satisfactory/__init__.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/worlds/satisfactory/__init__.py b/worlds/satisfactory/__init__.py index fc5c6f2edf..d105bd268c 100644 --- a/worlds/satisfactory/__init__.py +++ b/worlds/satisfactory/__init__.py @@ -244,14 +244,14 @@ class SatisfactoryWorld(World): or self.options.awesome_logic_placement.value == Placement.starting_inventory: locations_visible_from_start.update(range(1338700, 1338709)) # ids of shop locations 1 to 10 - location_names_with_useful_items: Iterable[str] = [ - location.name - for location in self.get_locations() - if location.address in locations_visible_from_start and location.item \ - and location.item.flags & (ItemClassification.progression | ItemClassification.useful) > 0 - ] + location_names_with_useful_items: Iterable[str] = [ + location.name + for location in self.get_locations() + if location.address in locations_visible_from_start and location.item \ + and location.item.flags & (ItemClassification.progression | ItemClassification.useful) > 0 + ] - self.options.start_location_hints.value.update(location_names_with_useful_items) + self.options.start_location_hints.value.update(location_names_with_useful_items) def push_precollected_by_name(self, item_name: str) -> None: item = self.create_item(item_name) From 96277fe9be7b072412454bfb8820a3b2cec366df Mon Sep 17 00:00:00 2001 From: el-u <109771707+el-u@users.noreply.github.com> Date: Sun, 29 Mar 2026 23:37:53 +0200 Subject: [PATCH 17/26] lufia2ac: update CollectionRule import (#5936) --- worlds/lufia2ac/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/worlds/lufia2ac/__init__.py b/worlds/lufia2ac/__init__.py index 96de24a4b6..d5b104dde4 100644 --- a/worlds/lufia2ac/__init__.py +++ b/worlds/lufia2ac/__init__.py @@ -5,11 +5,11 @@ from enum import IntFlag from typing import Any, ClassVar, Dict, Iterator, List, Set, Tuple, Type import settings -from BaseClasses import Item, ItemClassification, Location, MultiWorld, Region, Tutorial +from BaseClasses import CollectionRule, Item, ItemClassification, Location, MultiWorld, Region, Tutorial from Options import PerGameCommonOptions from Utils import __version__ from worlds.AutoWorld import WebWorld, World -from worlds.generic.Rules import add_rule, CollectionRule, set_rule +from worlds.generic.Rules import add_rule, set_rule from .Client import L2ACSNIClient # noqa: F401 from .Items import ItemData, ItemType, l2ac_item_name_to_id, l2ac_item_table, L2ACItem, start_id as items_start_id from .Locations import l2ac_location_name_to_id, L2ACLocation From 95f696c04f986909c57deade47406dfa731369e9 Mon Sep 17 00:00:00 2001 From: Duck <31627079+duckboycool@users.noreply.github.com> Date: Sun, 29 Mar 2026 16:19:54 -0600 Subject: [PATCH 18/26] WebHost: Remove space before comma separators in tutorial authors (#5999) * Remove space before comma * Factorio authors update * Simplify template --- WebHostLib/templates/tutorialLanding.html | 6 +----- worlds/factorio/__init__.py | 2 +- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/WebHostLib/templates/tutorialLanding.html b/WebHostLib/templates/tutorialLanding.html index a96da883b6..ac7f24f95a 100644 --- a/WebHostLib/templates/tutorialLanding.html +++ b/WebHostLib/templates/tutorialLanding.html @@ -20,11 +20,7 @@ {% for file_name, file_data in tutorial_data.files.items() %}
  • {{ file_data.language }} - by - {% for author in file_data.authors %} - {{ author }} - {% if not loop.last %}, {% endif %} - {% endfor %} + by {{ file_data.authors | join(", ") }}
  • {% endfor %} diff --git a/worlds/factorio/__init__.py b/worlds/factorio/__init__.py index ceb758669d..4483cf0238 100644 --- a/worlds/factorio/__init__.py +++ b/worlds/factorio/__init__.py @@ -37,7 +37,7 @@ class FactorioWeb(WebWorld): "English", "setup_en.md", "setup/en", - ["Berserker, Farrak Kilhn"] + ["Berserker", "Farrak Kilhn"] )] option_groups = option_groups From 88dc1359600ca48c009e36b2f8109ac06be7f030 Mon Sep 17 00:00:00 2001 From: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com> Date: Sun, 29 Mar 2026 23:32:06 +0100 Subject: [PATCH 19/26] APQuest: Various fixes (#6079) * Import Buffer from typing_extensions instead of collections.abc for 3.11 compat * always re-set sound volumes before playing * fix game window scaling if parent is vertical * make default volume lower --- worlds/apquest/client/custom_views.py | 2 +- worlds/apquest/client/graphics.py | 2 +- worlds/apquest/client/sounds.py | 7 +++++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/worlds/apquest/client/custom_views.py b/worlds/apquest/client/custom_views.py index 4c1d28c732..026aa1fc8d 100644 --- a/worlds/apquest/client/custom_views.py +++ b/worlds/apquest/client/custom_views.py @@ -77,7 +77,7 @@ class APQuestGrid(GridLayout): parent_width, parent_height = self.parent.size self_width_according_to_parent_height = parent_height * 12 / 11 - self_height_according_to_parent_width = parent_height * 11 / 12 + self_height_according_to_parent_width = parent_width * 11 / 12 if self_width_according_to_parent_height > parent_width: self.size = parent_width, self_height_according_to_parent_width diff --git a/worlds/apquest/client/graphics.py b/worlds/apquest/client/graphics.py index 535bf84ee8..0e31218c6f 100644 --- a/worlds/apquest/client/graphics.py +++ b/worlds/apquest/client/graphics.py @@ -1,10 +1,10 @@ import pkgutil -from collections.abc import Buffer from enum import Enum from io import BytesIO from typing import Literal, NamedTuple, Protocol, cast from kivy.uix.image import CoreImage +from typing_extensions import Buffer from CommonClient import logger diff --git a/worlds/apquest/client/sounds.py b/worlds/apquest/client/sounds.py index f4c4ab769d..1e1999bff2 100644 --- a/worlds/apquest/client/sounds.py +++ b/worlds/apquest/client/sounds.py @@ -1,12 +1,12 @@ import asyncio import pkgutil from asyncio import Task -from collections.abc import Buffer from pathlib import Path from typing import cast from kivy import Config from kivy.core.audio import Sound, SoundLoader +from typing_extensions import Buffer from CommonClient import logger @@ -85,7 +85,7 @@ class SoundManager: def ensure_config(self) -> None: Config.adddefaultsection("APQuest") - Config.setdefault("APQuest", "volume", 50) + Config.setdefault("APQuest", "volume", 30) self.set_volume_percentage(Config.getint("APQuest", "volume")) async def sound_manager_loop(self) -> None: @@ -149,6 +149,7 @@ class SoundManager: continue if sound_name == audio_filename: + sound.volume = self.volume_percentage / 100 sound.play() self.update_background_music() higher_priority_sound_is_playing = True @@ -213,6 +214,7 @@ class SoundManager: # It ends up feeling better if this just always continues playing quietly after being started. # Even "fading in at a random spot" is better than restarting the song after a jingle / math trap. if self.game_started and song.state == "stop": + song.volume = self.current_background_music_volume * self.volume_percentage / 100 song.play() song.seek(0) continue @@ -228,6 +230,7 @@ class SoundManager: if self.current_background_music_volume != 0: if song.state == "stop": + song.volume = self.current_background_music_volume * self.volume_percentage / 100 song.play() song.seek(0) From 2b46df90b44d62c11988ee792f37bd7b7351f262 Mon Sep 17 00:00:00 2001 From: Jarno Date: Mon, 30 Mar 2026 00:46:01 +0200 Subject: [PATCH 20/26] Satisfactory: Fixed buildings missing from goal check (#5772) --- worlds/satisfactory/__init__.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/worlds/satisfactory/__init__.py b/worlds/satisfactory/__init__.py index d105bd268c..06c56eabe5 100644 --- a/worlds/satisfactory/__init__.py +++ b/worlds/satisfactory/__init__.py @@ -88,16 +88,19 @@ class SatisfactoryWorld(World): self.items.build_item_pool(self.random, precollected_items, number_of_locations) def set_rules(self) -> None: - resource_sink_goal: bool = "AWESOME Sink Points (total)" in self.options.goal_selection \ - or "AWESOME Sink Points (per minute)" in self.options.goal_selection - required_parts = set(self.game_logic.space_elevator_phases[self.options.final_elevator_phase.value - 1].keys()) + required_buildings = set() - if resource_sink_goal: - required_parts.union(self.game_logic.buildings["AWESOME Sink"].inputs) + if "Space Elevator Phase" in self.options.goal_selection: + required_buildings.add("Space Elevator") + + if "AWESOME Sink Points (total)" in self.options.goal_selection \ + or "AWESOME Sink Points (per minute)" in self.options.goal_selection: + required_buildings.add("AWESOME Sink") self.multiworld.completion_condition[self.player] = \ - lambda state: self.state_logic.can_produce_all(state, required_parts) + lambda state: self.state_logic.can_produce_all(state, required_parts) \ + and self.state_logic.can_build_all(state, required_buildings) def collect(self, state: CollectionState, item: Item) -> bool: change = super().collect(state, item) From bdbf72f148be16aae62eb8d55c9e9fd87ea8ccb7 Mon Sep 17 00:00:00 2001 From: Louis M Date: Sun, 29 Mar 2026 19:40:05 -0400 Subject: [PATCH 21/26] Aquaria: Fixing bug where Urchin Costume is not a progression damaging item (#5998) --- worlds/aquaria/Items.py | 6 +++--- worlds/aquaria/Regions.py | 2 +- worlds/aquaria/__init__.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/worlds/aquaria/Items.py b/worlds/aquaria/Items.py index 3365c1fa59..b510b24738 100644 --- a/worlds/aquaria/Items.py +++ b/worlds/aquaria/Items.py @@ -271,7 +271,7 @@ item_table = { ItemNames.TRIDENT: ItemData(698031, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_trident_head ItemNames.TURTLE_EGG: ItemData(698032, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_turtle_egg ItemNames.JELLY_EGG: ItemData(698033, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_upsidedown_seed - ItemNames.URCHIN_COSTUME: ItemData(698034, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_urchin_costume + ItemNames.URCHIN_COSTUME: ItemData(698034, 1, ItemType.PROGRESSION, ItemGroup.COLLECTIBLE), # collectible_urchin_costume ItemNames.BABY_WALKER: ItemData(698035, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_walker ItemNames.VEDHA_S_CURE_ALL: ItemData(698036, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_Vedha'sCure-All ItemNames.ZUUNA_S_PEROGI: ItemData(698037, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_Zuuna'sperogi @@ -384,8 +384,8 @@ four_gods_excludes = [ItemNames.ANEMONE, ItemNames.ARNASSI_STATUE, ItemNames.BIG ItemNames.MITHALAS_BANNER, ItemNames.MITHALAS_POT, ItemNames.MUTANT_COSTUME, ItemNames.SEED_BAG, ItemNames.KING_S_SKULL, ItemNames.SONG_PLANT_SPORE, ItemNames.STONE_HEAD, ItemNames.SUN_KEY, ItemNames.GIRL_COSTUME, ItemNames.ODD_CONTAINER, ItemNames.TRIDENT, ItemNames.TURTLE_EGG, - ItemNames.JELLY_EGG, ItemNames.URCHIN_COSTUME, ItemNames.BABY_WALKER, - ItemNames.RAINBOW_MUSHROOM, ItemNames.RAINBOW_MUSHROOM, ItemNames.RAINBOW_MUSHROOM, + ItemNames.JELLY_EGG, ItemNames.BABY_WALKER, ItemNames.RAINBOW_MUSHROOM, + ItemNames.RAINBOW_MUSHROOM, ItemNames.RAINBOW_MUSHROOM, ItemNames.FISH_OIL, ItemNames.LEAF_POULTICE, ItemNames.LEAF_POULTICE, ItemNames.LEAF_POULTICE, ItemNames.LEECHING_POULTICE, ItemNames.LEECHING_POULTICE, ItemNames.ARCANE_POULTICE, ItemNames.ROTTEN_MEAT, ItemNames.ROTTEN_MEAT, ItemNames.ROTTEN_MEAT, ItemNames.ROTTEN_MEAT, diff --git a/worlds/aquaria/Regions.py b/worlds/aquaria/Regions.py index 781d06e09f..b32f9119ec 100755 --- a/worlds/aquaria/Regions.py +++ b/worlds/aquaria/Regions.py @@ -37,7 +37,7 @@ def _has_li(state: CollectionState, player: int) -> bool: DAMAGING_ITEMS:Iterable[str] = [ ItemNames.ENERGY_FORM, ItemNames.NATURE_FORM, ItemNames.BEAST_FORM, ItemNames.LI_AND_LI_SONG, ItemNames.BABY_NAUTILUS, ItemNames.BABY_PIRANHA, - ItemNames.BABY_BLASTER + ItemNames.BABY_BLASTER, ItemNames.URCHIN_COSTUME ] def _has_damaging_item(state: CollectionState, player: int, damaging_items:Iterable[str] = DAMAGING_ITEMS) -> bool: diff --git a/worlds/aquaria/__init__.py b/worlds/aquaria/__init__.py index 2997f21d04..395d349154 100644 --- a/worlds/aquaria/__init__.py +++ b/worlds/aquaria/__init__.py @@ -76,7 +76,7 @@ class AquariaWorld(World): item_name_groups = { "Damage": {ItemNames.ENERGY_FORM, ItemNames.NATURE_FORM, ItemNames.BEAST_FORM, ItemNames.LI_AND_LI_SONG, ItemNames.BABY_NAUTILUS, ItemNames.BABY_PIRANHA, - ItemNames.BABY_BLASTER}, + ItemNames.BABY_BLASTER, ItemNames.URCHIN_COSTUME}, "Light": {ItemNames.SUN_FORM, ItemNames.BABY_DUMBO} } """Grouping item make it easier to find them""" From ba7ca0bd2378175a6ebbe66d2e75c5c95c970660 Mon Sep 17 00:00:00 2001 From: EdricY Date: Mon, 30 Mar 2026 08:25:25 -0700 Subject: [PATCH 22/26] Options Creator: bind free text set_value to text instead of on_text_validate (#5915) --- OptionsCreator.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/OptionsCreator.py b/OptionsCreator.py index 94ca8ba7ac..30833993e1 100644 --- a/OptionsCreator.py +++ b/OptionsCreator.py @@ -384,10 +384,11 @@ class OptionsCreator(ThemedApp): def create_free_text(self, option: typing.Type[FreeText] | typing.Type[TextChoice], name: str): text = VisualFreeText(option=option, name=name) - def set_value(instance): - self.options[name] = instance.text + def set_value(instance, value): + self.options[name] = value - text.bind(on_text_validate=set_value) + text.bind(text=set_value) + self.options[name] = option.default return text def create_choice(self, option: typing.Type[Choice], name: str): From 58a6407040125cd90346dd2a523bc3d0f01f344f Mon Sep 17 00:00:00 2001 From: James White Date: Mon, 30 Mar 2026 16:27:10 +0100 Subject: [PATCH 23/26] SMW: Prevent receiving your own traps while aliased (#5763) --- worlds/smw/Client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/worlds/smw/Client.py b/worlds/smw/Client.py index 85524eb7ad..ef576a8738 100644 --- a/worlds/smw/Client.py +++ b/worlds/smw/Client.py @@ -132,7 +132,7 @@ class SMWSNIClient(SNIClient): self.instance_id = time.time() source_name = args["data"]["source"] - if "TrapLink" in ctx.tags and "TrapLink" in args["tags"] and source_name != ctx.slot_info[ctx.slot].name: + if "TrapLink" in ctx.tags and "TrapLink" in args["tags"] and source_name != ctx.player_names[ctx.slot]: trap_name: str = args["data"]["trap_name"] if trap_name not in trap_name_to_value: # We don't know how to handle this trap, ignore it From c640d2fa249c3a01e6f56a336b60eb4a4c62e38f Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Mon, 30 Mar 2026 12:19:10 -0400 Subject: [PATCH 24/26] Rule Builder: Add field resolvers (#5919) --- .github/pyright-config.json | 1 + docs/rule builder.md | 36 +++++++ rule_builder/field_resolvers.py | 162 ++++++++++++++++++++++++++++++ rule_builder/rules.py | 113 +++++++++++++++------ test/general/test_rule_builder.py | 67 ++++++++++-- 5 files changed, 341 insertions(+), 38 deletions(-) create mode 100644 rule_builder/field_resolvers.py diff --git a/.github/pyright-config.json b/.github/pyright-config.json index fba044da06..c5432dbf3c 100644 --- a/.github/pyright-config.json +++ b/.github/pyright-config.json @@ -3,6 +3,7 @@ "../BizHawkClient.py", "../Patch.py", "../rule_builder/cached_world.py", + "../rule_builder/field_resolvers.py", "../rule_builder/options.py", "../rule_builder/rules.py", "../test/param.py", diff --git a/docs/rule builder.md b/docs/rule builder.md index 4f9102a2ba..c3a8fcb6c4 100644 --- a/docs/rule builder.md +++ b/docs/rule builder.md @@ -129,6 +129,42 @@ common_rule_only_on_easy = common_rule & easy_filter common_rule_skipped_on_easy = common_rule | easy_filter ``` +### Field resolvers + +When creating rules you may sometimes need to set a field to a value that depends on the world instance. You can use a `FieldResolver` to define how to populate that field when the rule is being resolved. + +There are two build-in field resolvers: + +- `FromOption`: Resolves to the value of the given option +- `FromWorldAttr`: Resolves to the value of the given world instance attribute, can specify a dotted path `a.b.c` to get a nested attribute or dict item + +```python +world.options.mcguffin_count = 5 +world.precalculated_value = 99 +rule = ( + Has("A", count=FromOption(McguffinCount)) + | HasGroup("Important items", count=FromWorldAttr("precalculated_value")) +) +# Results in Has("A", count=5) | HasGroup("Important items", count=99) +``` + +You can define your own resolvers by creating a class that inherits from `FieldResolver`, provides your game name, and implements a `resolve` function: + +```python +@dataclasses.dataclass(frozen=True) +class FromCustomResolution(FieldResolver, game="MyGame"): + modifier: str + + @override + def resolve(self, world: "World") -> Any: + return some_math_calculation(world, self.modifier) + + +rule = Has("Combat Level", count=FromCustomResolution("combat")) +``` + +If you want to support rule serialization and your resolver contains non-serializable properties you may need to override `to_dict` or `from_dict`. + ## Enabling caching The rule builder provides a `CachedRuleBuilderWorld` base class for your `World` class that enables caching on your rules. diff --git a/rule_builder/field_resolvers.py b/rule_builder/field_resolvers.py new file mode 100644 index 0000000000..1e5def6b44 --- /dev/null +++ b/rule_builder/field_resolvers.py @@ -0,0 +1,162 @@ +import dataclasses +import importlib +from abc import ABC, abstractmethod +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, ClassVar, Self, TypeVar, cast, overload + +from typing_extensions import override + +from Options import Option + +if TYPE_CHECKING: + from worlds.AutoWorld import World + + +class FieldResolverRegister: + """A container class to contain world custom resolvers""" + + custom_resolvers: ClassVar[dict[str, dict[str, type["FieldResolver"]]]] = {} + """ + A mapping of game name to mapping of resolver name to resolver class + to hold custom resolvers implemented by worlds + """ + + @classmethod + def get_resolver_cls(cls, game_name: str, resolver_name: str) -> type["FieldResolver"]: + """Returns the world-registered or default resolver with the given name""" + custom_resolver_classes = cls.custom_resolvers.get(game_name, {}) + if resolver_name not in DEFAULT_RESOLVERS and resolver_name not in custom_resolver_classes: + raise ValueError(f"Resolver '{resolver_name}' for game '{game_name}' not found") + return custom_resolver_classes.get(resolver_name) or DEFAULT_RESOLVERS[resolver_name] + + +@dataclasses.dataclass(frozen=True) +class FieldResolver(ABC): + @abstractmethod + def resolve(self, world: "World") -> Any: ... + + def to_dict(self) -> dict[str, Any]: + """Returns a JSON compatible dict representation of this resolver""" + fields = {field.name: getattr(self, field.name, None) for field in dataclasses.fields(self)} + return { + "resolver": self.__class__.__name__, + **fields, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> Self: + """Returns a new instance of this resolver from a serialized dict representation""" + assert data.get("resolver", None) == cls.__name__ + return cls(**{k: v for k, v in data.items() if k != "resolver"}) + + @override + def __str__(self) -> str: + return self.__class__.__name__ + + @classmethod + def __init_subclass__(cls, /, game: str) -> None: + if game != "Archipelago": + custom_resolvers = FieldResolverRegister.custom_resolvers.setdefault(game, {}) + if cls.__qualname__ in custom_resolvers: + raise TypeError(f"Resolver {cls.__qualname__} has already been registered for game {game}") + custom_resolvers[cls.__qualname__] = cls + elif cls.__module__ != "rule_builder.field_resolvers": + raise TypeError("You cannot define custom resolvers for the base Archipelago world") + + +@dataclasses.dataclass(frozen=True) +class FromOption(FieldResolver, game="Archipelago"): + option: type[Option[Any]] + field: str = "value" + + @override + def resolve(self, world: "World") -> Any: + option_name = next( + (name for name, cls in world.options.__class__.type_hints.items() if cls is self.option), + None, + ) + + if option_name is None: + raise ValueError( + f"Cannot find option {self.option.__name__} in options class {world.options.__class__.__name__}" + ) + opt = cast(Option[Any] | None, getattr(world.options, option_name, None)) + if opt is None: + raise ValueError(f"Invalid option: {option_name}") + return getattr(opt, self.field) + + @override + def to_dict(self) -> dict[str, Any]: + return { + "resolver": "FromOption", + "option": f"{self.option.__module__}.{self.option.__name__}", + "field": self.field, + } + + @override + @classmethod + def from_dict(cls, data: dict[str, Any]) -> Self: + if "option" not in data: + raise ValueError("Missing required option") + + option_path = data["option"] + try: + option_mod_name, option_cls_name = option_path.rsplit(".", 1) + option_module = importlib.import_module(option_mod_name) + option = getattr(option_module, option_cls_name, None) + except (ValueError, ImportError) as e: + raise ValueError(f"Cannot parse option '{option_path}'") from e + if option is None or not issubclass(option, Option): + raise ValueError(f"Invalid option '{option_path}' returns type '{option}' instead of Option subclass") + + return cls(cast(type[Option[Any]], option), data.get("field", "value")) + + @override + def __str__(self) -> str: + field = f".{self.field}" if self.field != "value" else "" + return f"FromOption({self.option.__name__}{field})" + + +@dataclasses.dataclass(frozen=True) +class FromWorldAttr(FieldResolver, game="Archipelago"): + name: str + + @override + def resolve(self, world: "World") -> Any: + obj: Any = world + for field in self.name.split("."): + if obj is None: + return None + if isinstance(obj, Mapping): + obj = obj.get(field, None) # pyright: ignore[reportUnknownMemberType] + else: + obj = getattr(obj, field, None) + return obj + + @override + def __str__(self) -> str: + return f"FromWorldAttr({self.name})" + + +T = TypeVar("T") + + +@overload +def resolve_field(field: Any, world: "World", expected_type: type[T]) -> T: ... +@overload +def resolve_field(field: Any, world: "World", expected_type: None = None) -> Any: ... +def resolve_field(field: Any, world: "World", expected_type: type[T] | None = None) -> T | Any: + if isinstance(field, FieldResolver): + field = field.resolve(world) + if expected_type: + assert isinstance(field, expected_type), f"Expected type {expected_type} but got {type(field)}" + return field + + +DEFAULT_RESOLVERS = { + resolver_name: resolver_class + for resolver_name, resolver_class in locals().items() + if isinstance(resolver_class, type) + and issubclass(resolver_class, FieldResolver) + and resolver_class is not FieldResolver +} diff --git a/rule_builder/rules.py b/rule_builder/rules.py index 77a89c96c2..07c0607c1f 100644 --- a/rule_builder/rules.py +++ b/rule_builder/rules.py @@ -7,6 +7,7 @@ from typing_extensions import TypeVar, dataclass_transform, override from BaseClasses import CollectionState from NetUtils import JSONMessagePart +from .field_resolvers import FieldResolver, FieldResolverRegister, resolve_field from .options import OptionFilter if TYPE_CHECKING: @@ -108,11 +109,14 @@ class Rule(Generic[TWorld]): def to_dict(self) -> dict[str, Any]: """Returns a JSON compatible dict representation of this rule""" - args = { - field.name: getattr(self, field.name, None) - for field in dataclasses.fields(self) - if field.name not in ("options", "filtered_resolution") - } + args = {} + for field in dataclasses.fields(self): + if field.name in ("options", "filtered_resolution"): + continue + value = getattr(self, field.name, None) + if isinstance(value, FieldResolver): + value = value.to_dict() + args[field.name] = value return { "rule": self.__class__.__qualname__, "options": [o.to_dict() for o in self.options], @@ -124,7 +128,19 @@ class Rule(Generic[TWorld]): def from_dict(cls, data: Mapping[str, Any], world_cls: "type[World]") -> Self: """Returns a new instance of this rule from a serialized dict representation""" options = OptionFilter.multiple_from_dict(data.get("options", ())) - return cls(**data.get("args", {}), options=options, filtered_resolution=data.get("filtered_resolution", False)) + args = cls._parse_field_resolvers(data.get("args", {}), world_cls.game) + return cls(**args, options=options, filtered_resolution=data.get("filtered_resolution", False)) + + @classmethod + def _parse_field_resolvers(cls, data: Mapping[str, Any], game_name: str) -> dict[str, Any]: + result: dict[str, Any] = {} + for name, value in data.items(): + if isinstance(value, dict) and "resolver" in value: + resolver_cls = FieldResolverRegister.get_resolver_cls(game_name, value["resolver"]) # pyright: ignore[reportUnknownArgumentType] + result[name] = resolver_cls.from_dict(value) # pyright: ignore[reportUnknownArgumentType] + else: + result[name] = value + return result def __and__(self, other: "Rule[Any] | Iterable[OptionFilter] | OptionFilter") -> "Rule[TWorld]": """Combines two rules or a rule and an option filter into an And rule""" @@ -688,24 +704,24 @@ class Filtered(WrapperRule[TWorld], game="Archipelago"): class Has(Rule[TWorld], game="Archipelago"): """A rule that checks if the player has at least `count` of a given item""" - item_name: str + item_name: str | FieldResolver """The item to check for""" - count: int = 1 + count: int | FieldResolver = 1 """The count the player is required to have""" @override def _instantiate(self, world: TWorld) -> Rule.Resolved: return self.Resolved( - self.item_name, - self.count, + resolve_field(self.item_name, world, str), + count=resolve_field(self.count, world, int), player=world.player, caching_enabled=getattr(world, "rule_caching_enabled", False), ) @override def __str__(self) -> str: - count = f", count={self.count}" if self.count > 1 else "" + count = f", count={self.count}" if isinstance(self.count, FieldResolver) or self.count > 1 else "" options = f", options={self.options}" if self.options else "" return f"{self.__class__.__name__}({self.item_name}{count}{options})" @@ -991,7 +1007,7 @@ class HasAny(Rule[TWorld], game="Archipelago"): class HasAllCounts(Rule[TWorld], game="Archipelago"): """A rule that checks if the player has all of the specified counts of the given items""" - item_counts: dict[str, int] + item_counts: Mapping[str, int | FieldResolver] """A mapping of item name to count to check for""" @override @@ -1002,12 +1018,30 @@ class HasAllCounts(Rule[TWorld], game="Archipelago"): if len(self.item_counts) == 1: item = next(iter(self.item_counts)) return Has(item, self.item_counts[item]).resolve(world) + item_counts = tuple((name, resolve_field(count, world, int)) for name, count in self.item_counts.items()) return self.Resolved( - tuple(self.item_counts.items()), + item_counts, player=world.player, caching_enabled=getattr(world, "rule_caching_enabled", False), ) + @override + def to_dict(self) -> dict[str, Any]: + output = super().to_dict() + output["args"]["item_counts"] = { + key: value.to_dict() if isinstance(value, FieldResolver) else value + for key, value in output["args"]["item_counts"].items() + } + return output + + @override + @classmethod + def from_dict(cls, data: Mapping[str, Any], world_cls: "type[World]") -> Self: + args = data.get("args", {}) + item_counts = cls._parse_field_resolvers(args.get("item_counts", {}), world_cls.game) + options = OptionFilter.multiple_from_dict(data.get("options", ())) + return cls(item_counts, options=options, filtered_resolution=data.get("filtered_resolution", False)) + @override def __str__(self) -> str: items = ", ".join([f"{item} x{count}" for item, count in self.item_counts.items()]) @@ -1096,7 +1130,7 @@ class HasAllCounts(Rule[TWorld], game="Archipelago"): class HasAnyCount(Rule[TWorld], game="Archipelago"): """A rule that checks if the player has any of the specified counts of the given items""" - item_counts: dict[str, int] + item_counts: Mapping[str, int | FieldResolver] """A mapping of item name to count to check for""" @override @@ -1107,12 +1141,30 @@ class HasAnyCount(Rule[TWorld], game="Archipelago"): if len(self.item_counts) == 1: item = next(iter(self.item_counts)) return Has(item, self.item_counts[item]).resolve(world) + item_counts = tuple((name, resolve_field(count, world, int)) for name, count in self.item_counts.items()) return self.Resolved( - tuple(self.item_counts.items()), + item_counts, player=world.player, caching_enabled=getattr(world, "rule_caching_enabled", False), ) + @override + def to_dict(self) -> dict[str, Any]: + output = super().to_dict() + output["args"]["item_counts"] = { + key: value.to_dict() if isinstance(value, FieldResolver) else value + for key, value in output["args"]["item_counts"].items() + } + return output + + @override + @classmethod + def from_dict(cls, data: Mapping[str, Any], world_cls: "type[World]") -> Self: + args = data.get("args", {}) + item_counts = cls._parse_field_resolvers(args.get("item_counts", {}), world_cls.game) + options = OptionFilter.multiple_from_dict(data.get("options", ())) + return cls(item_counts, options=options, filtered_resolution=data.get("filtered_resolution", False)) + @override def __str__(self) -> str: items = ", ".join([f"{item} x{count}" for item, count in self.item_counts.items()]) @@ -1204,13 +1256,13 @@ class HasFromList(Rule[TWorld], game="Archipelago"): item_names: tuple[str, ...] """A tuple of item names to check for""" - count: int = 1 + count: int | FieldResolver = 1 """The number of items the player needs to have""" def __init__( self, *item_names: str, - count: int = 1, + count: int | FieldResolver = 1, options: Iterable[OptionFilter] = (), filtered_resolution: bool = False, ) -> None: @@ -1227,7 +1279,7 @@ class HasFromList(Rule[TWorld], game="Archipelago"): return Has(self.item_names[0], self.count).resolve(world) return self.Resolved( self.item_names, - self.count, + count=resolve_field(self.count, world, int), player=world.player, caching_enabled=getattr(world, "rule_caching_enabled", False), ) @@ -1235,7 +1287,7 @@ class HasFromList(Rule[TWorld], game="Archipelago"): @override @classmethod def from_dict(cls, data: Mapping[str, Any], world_cls: "type[World]") -> Self: - args = {**data.get("args", {})} + args = cls._parse_field_resolvers(data.get("args", {}), world_cls.game) item_names = args.pop("item_names", ()) options = OptionFilter.multiple_from_dict(data.get("options", ())) return cls(*item_names, **args, options=options, filtered_resolution=data.get("filtered_resolution", False)) @@ -1338,13 +1390,13 @@ class HasFromListUnique(Rule[TWorld], game="Archipelago"): item_names: tuple[str, ...] """A tuple of item names to check for""" - count: int = 1 + count: int | FieldResolver = 1 """The number of items the player needs to have""" def __init__( self, *item_names: str, - count: int = 1, + count: int | FieldResolver = 1, options: Iterable[OptionFilter] = (), filtered_resolution: bool = False, ) -> None: @@ -1354,14 +1406,15 @@ class HasFromListUnique(Rule[TWorld], game="Archipelago"): @override def _instantiate(self, world: TWorld) -> Rule.Resolved: - if len(self.item_names) == 0 or len(self.item_names) < self.count: + count = resolve_field(self.count, world, int) + if len(self.item_names) == 0 or len(self.item_names) < count: # match state.has_from_list_unique return False_().resolve(world) if len(self.item_names) == 1: return Has(self.item_names[0]).resolve(world) return self.Resolved( self.item_names, - self.count, + count, player=world.player, caching_enabled=getattr(world, "rule_caching_enabled", False), ) @@ -1369,7 +1422,7 @@ class HasFromListUnique(Rule[TWorld], game="Archipelago"): @override @classmethod def from_dict(cls, data: Mapping[str, Any], world_cls: "type[World]") -> Self: - args = {**data.get("args", {})} + args = cls._parse_field_resolvers(data.get("args", {}), world_cls.game) item_names = args.pop("item_names", ()) options = OptionFilter.multiple_from_dict(data.get("options", ())) return cls(*item_names, **args, options=options, filtered_resolution=data.get("filtered_resolution", False)) @@ -1468,7 +1521,7 @@ class HasGroup(Rule[TWorld], game="Archipelago"): item_name_group: str """The name of the item group containing the items""" - count: int = 1 + count: int | FieldResolver = 1 """The number of items the player needs to have""" @override @@ -1477,14 +1530,14 @@ class HasGroup(Rule[TWorld], game="Archipelago"): return self.Resolved( self.item_name_group, item_names, - self.count, + count=resolve_field(self.count, world, int), player=world.player, caching_enabled=getattr(world, "rule_caching_enabled", False), ) @override def __str__(self) -> str: - count = f", count={self.count}" if self.count > 1 else "" + count = f", count={self.count}" if isinstance(self.count, FieldResolver) or self.count > 1 else "" options = f", options={self.options}" if self.options else "" return f"{self.__class__.__name__}({self.item_name_group}{count}{options})" @@ -1542,7 +1595,7 @@ class HasGroupUnique(Rule[TWorld], game="Archipelago"): item_name_group: str """The name of the item group containing the items""" - count: int = 1 + count: int | FieldResolver = 1 """The number of items the player needs to have""" @override @@ -1551,14 +1604,14 @@ class HasGroupUnique(Rule[TWorld], game="Archipelago"): return self.Resolved( self.item_name_group, item_names, - self.count, + count=resolve_field(self.count, world, int), player=world.player, caching_enabled=getattr(world, "rule_caching_enabled", False), ) @override def __str__(self) -> str: - count = f", count={self.count}" if self.count > 1 else "" + count = f", count={self.count}" if isinstance(self.count, FieldResolver) or self.count > 1 else "" options = f", options={self.options}" if self.options else "" return f"{self.__class__.__name__}({self.item_name_group}{count}{options})" diff --git a/test/general/test_rule_builder.py b/test/general/test_rule_builder.py index 81003dcd87..85e239175d 100644 --- a/test/general/test_rule_builder.py +++ b/test/general/test_rule_builder.py @@ -6,8 +6,9 @@ from typing_extensions import override from BaseClasses import CollectionState, Item, ItemClassification, Location, MultiWorld, Region from NetUtils import JSONMessagePart -from Options import Choice, FreeText, Option, OptionSet, PerGameCommonOptions, Toggle +from Options import Choice, FreeText, Option, OptionSet, PerGameCommonOptions, Range, Toggle from rule_builder.cached_world import CachedRuleBuilderWorld +from rule_builder.field_resolvers import FieldResolver, FromOption, FromWorldAttr, resolve_field from rule_builder.options import Operator, OptionFilter from rule_builder.rules import ( And, @@ -59,12 +60,20 @@ class SetOption(OptionSet): valid_keys: ClassVar[set[str]] = {"one", "two", "three"} # pyright: ignore[reportIncompatibleVariableOverride] +class RangeOption(Range): + auto_display_name = True + range_start = 1 + range_end = 10 + default = 5 + + @dataclass class RuleBuilderOptions(PerGameCommonOptions): toggle_option: ToggleOption choice_option: ChoiceOption text_option: FreeTextOption set_option: SetOption + range_option: RangeOption GAME_NAME = "Rule Builder Test Game" @@ -659,14 +668,15 @@ class TestRules(RuleBuilderTestCase): self.assertFalse(resolved_rule(self.state)) def test_has_any_count(self) -> None: - item_counts = {"Item 1": 1, "Item 2": 2} + item_counts: dict[str, int | FieldResolver] = {"Item 1": 1, "Item 2": 2} rule = HasAnyCount(item_counts) resolved_rule = rule.resolve(self.world) self.world.register_rule_dependencies(resolved_rule) for item_name, count in item_counts.items(): item = self.world.create_item(item_name) - for _ in range(count): + num_items = resolve_field(count, self.world, int) + for _ in range(num_items): self.assertFalse(resolved_rule(self.state)) self.state.collect(item) self.assertTrue(resolved_rule(self.state)) @@ -763,7 +773,7 @@ class TestSerialization(RuleBuilderTestCase): rule: ClassVar[Rule[Any]] = And( Or( - Has("i1", count=4), + Has("i1", count=FromOption(RangeOption)), HasFromList("i2", "i3", "i4", count=2), HasAnyCount({"i5": 2, "i6": 3}), options=[OptionFilter(ToggleOption, 0)], @@ -771,7 +781,7 @@ class TestSerialization(RuleBuilderTestCase): Or( HasAll("i7", "i8"), HasAllCounts( - {"i9": 1, "i10": 5}, + {"i9": 1, "i10": FromWorldAttr("instance_data.i10_count")}, options=[OptionFilter(ToggleOption, 1, operator="ne")], filtered_resolution=True, ), @@ -811,7 +821,14 @@ class TestSerialization(RuleBuilderTestCase): "rule": "Has", "options": [], "filtered_resolution": False, - "args": {"item_name": "i1", "count": 4}, + "args": { + "item_name": "i1", + "count": { + "resolver": "FromOption", + "option": "test.general.test_rule_builder.RangeOption", + "field": "value", + }, + }, }, { "rule": "HasFromList", @@ -848,7 +865,12 @@ class TestSerialization(RuleBuilderTestCase): }, ], "filtered_resolution": True, - "args": {"item_counts": {"i9": 1, "i10": 5}}, + "args": { + "item_counts": { + "i9": 1, + "i10": {"resolver": "FromWorldAttr", "name": "instance_data.i10_count"}, + } + }, }, { "rule": "CanReachRegion", @@ -923,7 +945,7 @@ class TestSerialization(RuleBuilderTestCase): multiworld = setup_solo_multiworld(self.world_cls, steps=(), seed=0) world = multiworld.worlds[1] deserialized_rule = world.rule_from_dict(self.rule_dict) - self.assertEqual(deserialized_rule, self.rule, str(deserialized_rule)) + self.assertEqual(deserialized_rule, self.rule, f"\n{deserialized_rule}\n{self.rule}") class TestExplain(RuleBuilderTestCase): @@ -1342,3 +1364,32 @@ class TestExplain(RuleBuilderTestCase): "& False)", ) assert str(self.resolved_rule) == " ".join(expected) + + +@classvar_matrix( + rules=( + ( + Has("A", FromOption(RangeOption)), + Has.Resolved("A", count=5, player=1), + ), + ( + Has("B", FromWorldAttr("pre_calculated")), + Has.Resolved("B", count=3, player=1), + ), + ( + Has("C", FromWorldAttr("instance_data.key")), + Has.Resolved("C", count=7, player=1), + ), + ) +) +class TestFieldResolvers(RuleBuilderTestCase): + rules: ClassVar[tuple[Rule[Any], Rule.Resolved]] + + def test_simplify(self) -> None: + multiworld = setup_solo_multiworld(self.world_cls, steps=("generate_early",), seed=0) + world = multiworld.worlds[1] + world.pre_calculated = 3 # pyright: ignore[reportAttributeAccessIssue] + world.instance_data = {"key": 7} # pyright: ignore[reportAttributeAccessIssue] + rule, expected = self.rules + resolved_rule = rule.resolve(world) + self.assertEqual(resolved_rule, expected, f"\n{resolved_rule}\n{expected}") From 2ee20a3ac445a33e51bc160c073734de1fc4358f Mon Sep 17 00:00:00 2001 From: black-sliver <59490463+black-sliver@users.noreply.github.com> Date: Mon, 30 Mar 2026 19:46:43 +0000 Subject: [PATCH 25/26] CI: set permissions, update and pin actions, CodeQL for actions (#6073) * CI: reduce default permissions to minimum * CI: update pin actions Most of them. CodeQL and action-gh-release is untouched for now. Immutable actions and actions/* are pinned to version, other actions are pinned to hash. * CI: make use of archive: false in upload-artifact also set compression level and error behavior for scan-build upload. * CI: update codeql and enable scanning actions --- .github/workflows/analyze-modified-files.yml | 6 ++-- .github/workflows/build.yml | 31 ++++++++++---------- .github/workflows/codeql-analysis.yml | 24 ++++++++++----- .github/workflows/ctest.yml | 4 ++- .github/workflows/docker.yml | 16 +++++----- .github/workflows/label-pull-requests.yml | 2 +- .github/workflows/release.yml | 12 ++++---- .github/workflows/scan-build.yml | 10 +++++-- .github/workflows/strict-type-check.yml | 6 ++-- .github/workflows/unittests.yml | 10 ++++--- 10 files changed, 71 insertions(+), 50 deletions(-) diff --git a/.github/workflows/analyze-modified-files.yml b/.github/workflows/analyze-modified-files.yml index 862a050c51..79c4f983a4 100644 --- a/.github/workflows/analyze-modified-files.yml +++ b/.github/workflows/analyze-modified-files.yml @@ -14,6 +14,8 @@ env: BEFORE: ${{ github.event.before }} AFTER: ${{ github.event.after }} +permissions: {} + jobs: flake8-or-mypy: strategy: @@ -25,7 +27,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6.0.2 - name: "Determine modified files (pull_request)" if: github.event_name == 'pull_request' @@ -50,7 +52,7 @@ jobs: run: | echo "diff=." >> $GITHUB_ENV - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6.2.0 if: env.diff != '' with: python-version: '3.11' diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 772a6c0be3..8ed0c3523c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -41,9 +41,9 @@ jobs: runs-on: windows-latest steps: # - copy code below to release.yml - - - uses: actions/checkout@v4 + - uses: actions/checkout@v6.0.2 - name: Install python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6.2.0 with: python-version: '~3.12.7' check-latest: true @@ -82,7 +82,7 @@ jobs: # - copy code above to release.yml - - name: Attest Build if: ${{ github.event_name == 'workflow_dispatch' }} - uses: actions/attest-build-provenance@v2 + uses: actions/attest@v4.1.0 with: subject-path: | build/exe.*/ArchipelagoLauncher.exe @@ -110,18 +110,17 @@ jobs: cp Players/Templates/VVVVVV.yaml Players/ timeout 30 ./ArchipelagoGenerate - name: Store 7z - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7.0.0 with: - name: ${{ env.ZIP_NAME }} path: dist/${{ env.ZIP_NAME }} - compression-level: 0 # .7z is incompressible by zip + archive: false if-no-files-found: error retention-days: 7 # keep for 7 days, should be enough - name: Store Setup - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7.0.0 with: - name: ${{ env.SETUP_NAME }} path: setups/${{ env.SETUP_NAME }} + archive: false if-no-files-found: error retention-days: 7 # keep for 7 days, should be enough @@ -129,14 +128,14 @@ jobs: runs-on: ubuntu-22.04 steps: # - copy code below to release.yml - - - uses: actions/checkout@v4 + - uses: actions/checkout@v6.0.2 - name: Install base dependencies run: | sudo apt update sudo apt -y install build-essential p7zip xz-utils wget libglib2.0-0 sudo apt -y install python3-gi libgirepository1.0-dev # should pull dependencies for gi installation below - name: Get a recent python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6.2.0 with: python-version: '~3.12.7' check-latest: true @@ -173,7 +172,7 @@ jobs: # - copy code above to release.yml - - name: Attest Build if: ${{ github.event_name == 'workflow_dispatch' }} - uses: actions/attest-build-provenance@v2 + uses: actions/attest@v4.1.0 with: subject-path: | build/exe.*/ArchipelagoLauncher @@ -204,17 +203,17 @@ jobs: cp Players/Templates/VVVVVV.yaml Players/ timeout 30 ./ArchipelagoGenerate - name: Store AppImage - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7.0.0 with: - name: ${{ env.APPIMAGE_NAME }} path: dist/${{ env.APPIMAGE_NAME }} + archive: false + # TODO: decide if we want to also upload the zsync if-no-files-found: error retention-days: 7 - name: Store .tar.gz - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7.0.0 with: - name: ${{ env.TAR_NAME }} path: dist/${{ env.TAR_NAME }} - compression-level: 0 # .gz is incompressible by zip + archive: false if-no-files-found: error retention-days: 7 diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 3abbb5f644..5751dce857 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -17,17 +17,26 @@ on: paths: - '**.py' - '**.js' - - '.github/workflows/codeql-analysis.yml' + - '.github/workflows/*.yml' + - '.github/workflows/*.yaml' + - '**/action.yml' + - '**/action.yaml' pull_request: # The branches below must be a subset of the branches above branches: [ main ] paths: - '**.py' - '**.js' - - '.github/workflows/codeql-analysis.yml' + - '.github/workflows/*.yml' + - '.github/workflows/*.yaml' + - '**/action.yml' + - '**/action.yaml' schedule: - cron: '44 8 * * 1' +permissions: + security-events: write + jobs: analyze: name: Analyze @@ -36,18 +45,17 @@ jobs: strategy: fail-fast: false matrix: - language: [ 'javascript', 'python' ] - # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] + language: [ 'javascript', 'python', 'actions' ] # Learn more: # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6.0.2 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@v4.35.1 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -58,7 +66,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v3 + uses: github/codeql-action/autobuild@v4.35.1 # â„šī¸ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl @@ -72,4 +80,4 @@ jobs: # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@v4.35.1 diff --git a/.github/workflows/ctest.yml b/.github/workflows/ctest.yml index 610f6d7477..1a39afa11d 100644 --- a/.github/workflows/ctest.yml +++ b/.github/workflows/ctest.yml @@ -24,6 +24,8 @@ on: - '**/CMakeLists.txt' - '.github/workflows/ctest.yml' +permissions: {} + jobs: ctest: runs-on: ${{ matrix.os }} @@ -35,7 +37,7 @@ jobs: os: [ubuntu-latest, windows-latest] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6.0.2 - uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756 if: startsWith(matrix.os,'windows') - uses: Bacondish2023/setup-googletest@49065d1f7a6d21f6134864dd65980fe5dbe06c73 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 0061dd15b0..231fb59dc5 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -19,6 +19,8 @@ on: env: REGISTRY: ghcr.io +permissions: {} + jobs: prepare: runs-on: ubuntu-latest @@ -29,7 +31,7 @@ jobs: package-name: ${{ steps.package.outputs.name }} steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6.0.2 - name: Set lowercase image name id: image @@ -43,7 +45,7 @@ jobs: - name: Extract metadata id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6.0.0 with: images: ${{ env.REGISTRY }}/${{ steps.image.outputs.name }} tags: | @@ -92,13 +94,13 @@ jobs: cache-scope: arm64 steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6.0.2 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - name: Log in to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -115,7 +117,7 @@ jobs: echo "tags=$(IFS=','; echo "${suffixed[*]}")" >> $GITHUB_OUTPUT - name: Build and push Docker image - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v7.0.0 with: context: . file: ./Dockerfile @@ -135,7 +137,7 @@ jobs: packages: write steps: - name: Log in to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} diff --git a/.github/workflows/label-pull-requests.yml b/.github/workflows/label-pull-requests.yml index 1675c942bd..341735e5dd 100644 --- a/.github/workflows/label-pull-requests.yml +++ b/.github/workflows/label-pull-requests.yml @@ -14,7 +14,7 @@ jobs: name: 'Apply content-based labels' runs-on: ubuntu-latest steps: - - uses: actions/labeler@v5 + - uses: actions/labeler@v6.0.1 with: sync-labels: false peer_review: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7f81e57507..21e1a24b88 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -48,9 +48,9 @@ jobs: shell: bash run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV # - code below copied from build.yml - - - uses: actions/checkout@v4 + - uses: actions/checkout@v6.0.2 - name: Install python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6.2.0 with: python-version: '~3.12.7' check-latest: true @@ -88,7 +88,7 @@ jobs: echo "SETUP_NAME=$SETUP_NAME" >> $Env:GITHUB_ENV # - code above copied from build.yml - - name: Attest Build - uses: actions/attest-build-provenance@v2 + uses: actions/attest@v4.1.0 with: subject-path: | build/exe.*/ArchipelagoLauncher.exe @@ -114,14 +114,14 @@ jobs: - name: Set env run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV # - code below copied from build.yml - - - uses: actions/checkout@v4 + - uses: actions/checkout@v6.0.2 - name: Install base dependencies run: | sudo apt update sudo apt -y install build-essential p7zip xz-utils wget libglib2.0-0 sudo apt -y install python3-gi libgirepository1.0-dev # should pull dependencies for gi installation below - name: Get a recent python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6.2.0 with: python-version: '~3.12.7' check-latest: true @@ -157,7 +157,7 @@ jobs: echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV # - code above copied from build.yml - - name: Attest Build - uses: actions/attest-build-provenance@v2 + uses: actions/attest@v4.1.0 with: subject-path: | build/exe.*/ArchipelagoLauncher diff --git a/.github/workflows/scan-build.yml b/.github/workflows/scan-build.yml index ac84207062..64f51af4a2 100644 --- a/.github/workflows/scan-build.yml +++ b/.github/workflows/scan-build.yml @@ -28,12 +28,14 @@ on: - 'requirements.txt' - '.github/workflows/scan-build.yml' +permissions: {} + jobs: scan-build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6.0.2 with: submodules: recursive - name: Install newer Clang @@ -45,7 +47,7 @@ jobs: run: | sudo apt install clang-tools-19 - name: Get a recent python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6.2.0 with: python-version: '3.11' - name: Install dependencies @@ -59,7 +61,9 @@ jobs: scan-build-19 --status-bugs -o scan-build-reports -disable-checker deadcode.DeadStores python setup.py build -y - name: Store report if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7.0.0 with: name: scan-build-reports path: scan-build-reports + compression-level: 9 # highly compressible + if-no-files-found: error diff --git a/.github/workflows/strict-type-check.yml b/.github/workflows/strict-type-check.yml index 2ccdad8d11..4a876bf98e 100644 --- a/.github/workflows/strict-type-check.yml +++ b/.github/workflows/strict-type-check.yml @@ -14,13 +14,15 @@ on: - ".github/workflows/strict-type-check.yml" - "**.pyi" +permissions: {} + jobs: pyright: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6.0.2 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6.2.0 with: python-version: "3.11" diff --git a/.github/workflows/unittests.yml b/.github/workflows/unittests.yml index b08b389005..cfffa6cc4a 100644 --- a/.github/workflows/unittests.yml +++ b/.github/workflows/unittests.yml @@ -29,6 +29,8 @@ on: - '!.github/workflows/**' - '.github/workflows/unittests.yml' +permissions: {} + jobs: unit: runs-on: ${{ matrix.os }} @@ -51,9 +53,9 @@ jobs: os: macos-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6.0.2 - name: Set up Python ${{ matrix.python.version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6.2.0 with: python-version: ${{ matrix.python.version }} - name: Install dependencies @@ -78,9 +80,9 @@ jobs: - {version: '3.13'} # current steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6.0.2 - name: Set up Python ${{ matrix.python.version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6.2.0 with: python-version: ${{ matrix.python.version }} - name: Install dependencies From 5360b6bb3727505ce5987b1e1dff316f4d792574 Mon Sep 17 00:00:00 2001 From: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com> Date: Mon, 30 Mar 2026 23:31:05 +0100 Subject: [PATCH 26/26] The Witness: (Unbeatable seed) Ensure Desert Laser Redirection is required when the box is rotated (#5889) * Unbeatable seed: 11 lasers + redirect when the box is rotated * naming --- worlds/witness/player_logic.py | 4 ++-- worlds/witness/test/test_lasers.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/worlds/witness/player_logic.py b/worlds/witness/player_logic.py index aed6d3da66..b24434732f 100644 --- a/worlds/witness/player_logic.py +++ b/worlds/witness/player_logic.py @@ -527,7 +527,7 @@ class WitnessPlayerLogic: if chal_lasers > 7: postgame_adjustments.append([ "Requirement Changes:", - "0xFFF00 - 11 Lasers - True", + "0xFFF00 - 11 Lasers + Redirect - True", ]) if disable_challenge_lasers: @@ -640,7 +640,7 @@ class WitnessPlayerLogic: if chal_lasers <= 7 or mnt_lasers > 7: adjustment_linesets_in_order.append([ "Requirement Changes:", - "0xFFF00 - 11 Lasers - True", + "0xFFF00 - 11 Lasers + Redirect - True", ]) if world.options.disable_non_randomized_puzzles: diff --git a/worlds/witness/test/test_lasers.py b/worlds/witness/test/test_lasers.py index 5681757161..4a71c0d433 100644 --- a/worlds/witness/test/test_lasers.py +++ b/worlds/witness/test/test_lasers.py @@ -216,3 +216,32 @@ class TestDoorsRequiredToWinElevator(WitnessTestBase): } self.assert_can_beat_with_minimally(exact_requirement) + + +class LongBoxNeedsAllLasersWhenBoxIsRotated(WitnessTestBase): + options = { + "puzzle_randomization": "sigma_expert", + "shuffle_symbols": True, + "shuffle_doors": "mixed", + "door_groupings": "off", + "shuffle_boat": True, + "shuffle_lasers": "anywhere", + "disable_non_randomized_puzzles": False, + "shuffle_discarded_panels": True, + "shuffle_vault_boxes": True, + "obelisk_keys": True, + "shuffle_EPs": "individual", + "EP_difficulty": "eclipse", + "shuffle_postgame": False, + "victory_condition": "elevator", + "mountain_lasers": 11, + "challenge_lasers": 11, + "early_caves": "off", + "elevators_come_to_you": {"Quarry Elevator"}, + } + + run_default_tests = False + + def test_long_box_needs_all_lasers_when_box_is_rotated(self): + long_box_location = self.world.get_location("Mountaintop Box Long Solved") + self.assert_dependency_on_event_item(long_box_location, "+1 Laser (Redirected)")