From 0265f4d80947d02bf2b8b261f3c3ca469c117056 Mon Sep 17 00:00:00 2001 From: Bryce Wilson Date: Tue, 4 Jun 2024 05:06:41 -0700 Subject: [PATCH 01/36] BizHawkClient: Reset finished_game if ROM changes (#3246) --- worlds/_bizhawk/context.py | 1 + 1 file changed, 1 insertion(+) diff --git a/worlds/_bizhawk/context.py b/worlds/_bizhawk/context.py index 9fe6c9e1ffb1..234faf3b65cf 100644 --- a/worlds/_bizhawk/context.py +++ b/worlds/_bizhawk/context.py @@ -168,6 +168,7 @@ async def _game_watcher(ctx: BizHawkClientContext): ctx.auth = None ctx.username = None ctx.client_handler = None + ctx.finished_game = False await ctx.disconnect(False) ctx.rom_hash = rom_hash From c4e0b17de3ddb9cb46f39b6baa392bd7c9bd3803 Mon Sep 17 00:00:00 2001 From: Scipio Wright Date: Tue, 4 Jun 2024 15:14:29 -0400 Subject: [PATCH 02/36] TUNIC: Add ice grapple logic to get to gauntlet (#3459) --- worlds/tunic/rules.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/worlds/tunic/rules.py b/worlds/tunic/rules.py index 12810cfa2670..0b65c8158e10 100644 --- a/worlds/tunic/rules.py +++ b/worlds/tunic/rules.py @@ -312,7 +312,9 @@ def set_location_rules(world: "TunicWorld", ability_unlocks: Dict[str, int]) -> # Swamp set_rule(multiworld.get_location("Cathedral Gauntlet - Gauntlet Reward", player), - lambda state: state.has(laurels, player) and state.has(fire_wand, player) and has_sword(state, player)) + lambda state: (state.has(fire_wand, player) and has_sword(state, player)) + and (state.has(laurels, player) + or has_ice_grapple_logic(False, state, player, options, ability_unlocks))) set_rule(multiworld.get_location("Swamp - [Entrance] Above Entryway", player), lambda state: state.has(laurels, player)) set_rule(multiworld.get_location("Swamp - [South Graveyard] Upper Walkway Dash Chest", player), From 16ae8449f45674c1cacc339cb2d2492a06337017 Mon Sep 17 00:00:00 2001 From: CookieCat <81494827+CookieCat45@users.noreply.github.com> Date: Tue, 4 Jun 2024 15:15:28 -0400 Subject: [PATCH 03/36] AHIT: Fix Death Wish location rules not being added properly (#3455) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * duh * Fuck it * Major fixes * a * b * Even more fixes * New option - NoFreeRoamFinale * a * Hat Logic Fix * Just to be safe * multiworld.random to world.random * KeyError fix * Update .gitignore * Update __init__.py * Zoinks Scoob * ffs * Ruh Roh Raggy, more r-r-r-random bugs! * 0.9b - cleanup + expanded logic difficulty * Update Rules.py * Update Regions.py * AttributeError fix * 0.10b - New Options * 1.0 Preparations * Docs * Docs 2 * Fixes * Update __init__.py * Fixes * variable capture my beloathed * Fixes * a * 10 Seconds logic fix * 1.1 * 1.2 * a * New client * More client changes * 1.3 * Final touch-ups for 1.3 * 1.3.1 * 1.3.3 * Zero Jumps gen error fix * more fixes * Formatting improvements * typo * Update __init__.py * Revert "Update __init__.py" This reverts commit e178a7c0a6904ace803241cab3021d7b97177e90. * init * Update to new options API * Missed some * Snatcher Coins fix * Missed some more * some slight touch ups * rewind * a * fix things * Revert "Merge branch 'main' of https://github.com/CookieCat45/Archipelago-ahit" This reverts commit a2360fe197e77a723bb70006c5eb5725c7ed3826, reversing changes made to b8948bc4958855c6e342e18bdb8dc81cfcf09455. * Update .gitignore * 1.3.6 * Final touch-ups * Fix client and leftover old options api * Delete setup-ahitclient.py * Update .gitignore * old python version fix * proper warnings for invalid act plandos * Update worlds/ahit/docs/en_A Hat in Time.md Co-authored-by: Danaël V. <104455676+ReverM@users.noreply.github.com> * Update worlds/ahit/docs/setup_en.md Co-authored-by: Danaël V. <104455676+ReverM@users.noreply.github.com> * 120 char per line * "settings" to "options" * Update DeathWishRules.py * Update worlds/ahit/docs/en_A Hat in Time.md Co-authored-by: Nicholas Saylor <79181893+nicholassaylor@users.noreply.github.com> * No more loading the data package * cleanup + act plando fixes * almost forgot * Update Rules.py * a * Update worlds/ahit/Options.py Co-authored-by: Ixrec * Options stuff * oop * no unnecessary type hints * warn about depot download length in setup guide * Update worlds/ahit/Options.py Co-authored-by: Ixrec * typo Co-authored-by: Ixrec * Update worlds/ahit/Rules.py Co-authored-by: Ixrec * review stuff * More stuff from review * comment * 1.5 Update * link fix? * link fix 2 * Update setup_en.md * Update setup_en.md * Update setup_en.md * Evil * Good fucking lord * Review stuff again + Logic fixes * More review stuff * Even more review stuff - we're almost done * DW review stuff * Finish up review stuff * remove leftover stuff * a * assert item * add A Hat in Time to readme/codeowners files * Fix range options not being corrected properly * 120 chars per line in docs * Update worlds/ahit/Regions.py Co-authored-by: Aaron Wagener * Update worlds/ahit/DeathWishLocations.py Co-authored-by: Aaron Wagener * Remove some unnecessary option.class.value * Remove data_version and more option.class.value * Update worlds/ahit/Items.py Co-authored-by: Aaron Wagener * Remove the rest of option.class.value * Update worlds/ahit/DeathWishLocations.py Co-authored-by: Aaron Wagener * review stuff * Replace connect_regions with Region.connect * review stuff * Remove unnecessary Optional from LocData * Remove HatType.NONE * Update worlds/ahit/test/TestActs.py Co-authored-by: Aaron Wagener * fix so default tests actually don't run * Improve performance for death wish rules * rename test file * change test imports * 1000 is probably unnecessary * a * change state.count to state.has * stuff * starting inventory hats fix * shouldn't have done this lol * make ship shape task goal equal to number of tasksanity checks if set to 0 * a * change act shuffle starting acts + logic updates * dumb * option groups + lambda capture cringe + typo * a * b * missing option in groups * c * Fix Your Contract Has Expired being placed on first level when it shouldn't * formatting * major logic bug fix for death wish --------- Co-authored-by: Danaël V. <104455676+ReverM@users.noreply.github.com> Co-authored-by: Nicholas Saylor <79181893+nicholassaylor@users.noreply.github.com> Co-authored-by: Ixrec Co-authored-by: Aaron Wagener Co-authored-by: Fabian Dill --- worlds/ahit/DeathWishRules.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/worlds/ahit/DeathWishRules.py b/worlds/ahit/DeathWishRules.py index 50fafd0a4d08..1432ef5c0d75 100644 --- a/worlds/ahit/DeathWishRules.py +++ b/worlds/ahit/DeathWishRules.py @@ -35,7 +35,7 @@ "The Mustache Gauntlet": LocData(hookshot=True, required_hats=[HatType.DWELLER]), - "Rift Collapse - Deep Sea": LocData(hookshot=True), + "Rift Collapse: Deep Sea": LocData(hookshot=True), } # Includes main objective requirements @@ -55,7 +55,7 @@ "The Mustache Gauntlet": LocData(required_hats=[HatType.ICE]), - "Rift Collapse - Deep Sea": LocData(required_hats=[HatType.DWELLER]), + "Rift Collapse: Deep Sea": LocData(required_hats=[HatType.DWELLER]), } dw_stamp_costs = { @@ -178,9 +178,9 @@ def set_dw_rules(world: "HatInTimeWorld"): def add_dw_rules(world: "HatInTimeWorld", loc: Location): bonus: bool = "All Clear" in loc.name if not bonus: - data = dw_requirements.get(loc.name) + data = dw_requirements.get(loc.parent_region.name) else: - data = dw_bonus_requirements.get(loc.name) + data = dw_bonus_requirements.get(loc.parent_region.name) if data is None: return From c4572964eca1c11a8f88f11a809418351a421ee1 Mon Sep 17 00:00:00 2001 From: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> Date: Tue, 4 Jun 2024 15:20:37 -0400 Subject: [PATCH 04/36] KH2: Fixing Start Inventory bug, limiting CustomItemPool keys, fixing two typos (#3444) * Fixing inclusion checking error * Fixing typo, limiting valid keys to valid keys * Adding space * Add period --- worlds/kh2/Options.py | 4 ++-- worlds/kh2/__init__.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/worlds/kh2/Options.py b/worlds/kh2/Options.py index ffe95d1d5f25..ddaf36ebcbf9 100644 --- a/worlds/kh2/Options.py +++ b/worlds/kh2/Options.py @@ -308,14 +308,14 @@ class CorSkipToggle(Toggle): Full Cor Skip is also affected by this Toggle. """ - display_name = "CoR Skip Toggle." + display_name = "CoR Skip Toggle" default = False class CustomItemPoolQuantity(ItemDict): """Add more of an item into the itempool. Note: You cannot take out items from the pool.""" display_name = "Custom Item Pool" - verify_item_name = True + valid_keys = default_itempool_option.keys() default = default_itempool_option diff --git a/worlds/kh2/__init__.py b/worlds/kh2/__init__.py index 15cfa11c93cf..faf0bed88567 100644 --- a/worlds/kh2/__init__.py +++ b/worlds/kh2/__init__.py @@ -430,13 +430,13 @@ def starting_invo_verify(self): """ for item, value in self.options.start_inventory.value.items(): if item in ActionAbility_Table \ - or item in SupportAbility_Table or exclusion_item_table["StatUps"] \ + or item in SupportAbility_Table or item in exclusion_item_table["StatUps"] \ or item in DonaldAbility_Table or item in GoofyAbility_Table: # cannot have more than the quantity for abilties if value > item_dictionary_table[item].quantity: logging.info( - f"{self.multiworld.get_file_safe_player_name(self.player)} cannot have more than {item_dictionary_table[item].quantity} of {item}" - f"Changing the amount to the max amount") + f"{self.multiworld.get_file_safe_player_name(self.player)} cannot have more than {item_dictionary_table[item].quantity} of {item}." + f" Changing the amount to the max amount") value = item_dictionary_table[item].quantity self.item_quantity_dict[item] -= value From ee1b13f2191a1e0667a75c735f9533276fb1227f Mon Sep 17 00:00:00 2001 From: Bryce Wilson Date: Tue, 4 Jun 2024 12:21:58 -0700 Subject: [PATCH 05/36] Pokemon Emerald: Fix possible dexsanity/legendary hunt softlock (#3443) * Pokemon Emerald: Remove mirage tower from allowed dexsanity maps * Pokemon Emerald: Prevent placing wailord/relicanth in out of logic maps * Pokemon Emerald: Clarify docstring Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> * Pokemon Emerald: Update changelog --------- Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> --- worlds/pokemon_emerald/CHANGELOG.md | 1 + worlds/pokemon_emerald/data.py | 11 +++++++++-- worlds/pokemon_emerald/pokemon.py | 10 +++++----- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/worlds/pokemon_emerald/CHANGELOG.md b/worlds/pokemon_emerald/CHANGELOG.md index e967b2039b12..0437c0dae8ff 100644 --- a/worlds/pokemon_emerald/CHANGELOG.md +++ b/worlds/pokemon_emerald/CHANGELOG.md @@ -12,6 +12,7 @@ and won't show up in the wild. Previously they would be forced to show up exactl - The Lilycove Wailmer now logically block you from the east. Actual game behavior is still unchanged for now. - Water encounters in Slateport now correctly require Surf. +- Mirage Tower can no longer be your only logical access to a species in the wild, since it can permanently disappear. - Updated the tracker link in the setup guide. # 2.1.1 diff --git a/worlds/pokemon_emerald/data.py b/worlds/pokemon_emerald/data.py index e717a225561c..d89ab5febb33 100644 --- a/worlds/pokemon_emerald/data.py +++ b/worlds/pokemon_emerald/data.py @@ -25,13 +25,20 @@ } """These maps exist but don't show up in the rando or are unused, and so should be discarded""" -POSTGAME_MAPS = { +OUT_OF_LOGIC_MAPS = { "MAP_DESERT_UNDERPASS", "MAP_SAFARI_ZONE_NORTHEAST", "MAP_SAFARI_ZONE_SOUTHEAST", "MAP_METEOR_FALLS_STEVENS_CAVE", + "MAP_MIRAGE_TOWER_1F", + "MAP_MIRAGE_TOWER_2F", + "MAP_MIRAGE_TOWER_3F", + "MAP_MIRAGE_TOWER_4F", } -"""These maps have encounters and are locked behind beating the champion. Those encounter slots should be ignored for logical access to a species.""" +""" +These maps have encounters and are locked behind beating the champion or are missable. +Those encounter slots should be ignored for logical access to a species. +""" NUM_REAL_SPECIES = 386 diff --git a/worlds/pokemon_emerald/pokemon.py b/worlds/pokemon_emerald/pokemon.py index 8aa25934af8d..c60e5e9d4f14 100644 --- a/worlds/pokemon_emerald/pokemon.py +++ b/worlds/pokemon_emerald/pokemon.py @@ -4,9 +4,8 @@ import functools from typing import TYPE_CHECKING, Dict, List, Set, Optional, Tuple -from Options import Toggle - -from .data import NUM_REAL_SPECIES, POSTGAME_MAPS, EncounterTableData, LearnsetMove, MiscPokemonData, SpeciesData, data +from .data import (NUM_REAL_SPECIES, OUT_OF_LOGIC_MAPS, EncounterTableData, LearnsetMove, MiscPokemonData, + SpeciesData, data) from .options import (Goal, HmCompatibility, LevelUpMoves, RandomizeAbilities, RandomizeLegendaryEncounters, RandomizeMiscPokemon, RandomizeStarters, RandomizeTypes, RandomizeWildPokemon, TmTutorCompatibility) @@ -266,7 +265,8 @@ def randomize_wild_encounters(world: "PokemonEmeraldWorld") -> None: species_old_to_new_map: Dict[int, int] = {} for species_id in table.slots: if species_id not in species_old_to_new_map: - if not placed_priority_species and len(priority_species) > 0: + if not placed_priority_species and len(priority_species) > 0 \ + and map_name not in OUT_OF_LOGIC_MAPS: new_species_id = priority_species.pop() placed_priority_species = True else: @@ -329,7 +329,7 @@ def randomize_wild_encounters(world: "PokemonEmeraldWorld") -> None: new_species_id = world.random.choice(candidates).species_id species_old_to_new_map[species_id] = new_species_id - if world.options.dexsanity and map_data.name not in POSTGAME_MAPS: + if world.options.dexsanity and map_name not in OUT_OF_LOGIC_MAPS: already_placed.add(new_species_id) # Actually create the new list of slots and encounter table From f30f2d3a3feb9dc825b77f2aea489bf3786b5a74 Mon Sep 17 00:00:00 2001 From: Rjosephson Date: Tue, 4 Jun 2024 13:24:14 -0600 Subject: [PATCH 06/36] RoR2: Add Support for New Stage (#3436) * add support for the new stage added to RoR2 * Fix stage being unreachable * add option groups * reorder option groups --- worlds/ror2/__init__.py | 6 +++-- worlds/ror2/options.py | 46 ++++++++++++++++++++++++++++++--- worlds/ror2/regions.py | 4 ++- worlds/ror2/ror2environments.py | 1 + 4 files changed, 50 insertions(+), 7 deletions(-) diff --git a/worlds/ror2/__init__.py b/worlds/ror2/__init__.py index b6a1901a8db1..7873ae54bbba 100644 --- a/worlds/ror2/__init__.py +++ b/worlds/ror2/__init__.py @@ -7,7 +7,7 @@ environment_sotv_orderedstages_table, environment_sotv_table, collapse_dict_list_vertical, shift_by_offset from BaseClasses import Item, ItemClassification, Tutorial -from .options import ItemWeights, ROR2Options +from .options import ItemWeights, ROR2Options, ror2_option_groups from worlds.AutoWorld import World, WebWorld from .regions import create_explore_regions, create_classic_regions from typing import List, Dict, Any @@ -23,6 +23,8 @@ class RiskOfWeb(WebWorld): ["Ijwu", "Kindasneaki"] )] + option_groups = ror2_option_groups + class RiskOfRainWorld(World): """ @@ -44,7 +46,7 @@ class RiskOfRainWorld(World): } location_name_to_id = item_pickups - required_client_version = (0, 4, 5) + required_client_version = (0, 5, 0) web = RiskOfWeb() total_revivals: int diff --git a/worlds/ror2/options.py b/worlds/ror2/options.py index 066c8c8545a8..381c5942b07b 100644 --- a/worlds/ror2/options.py +++ b/worlds/ror2/options.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from Options import Toggle, DefaultOnToggle, DeathLink, Range, Choice, PerGameCommonOptions +from Options import Toggle, DefaultOnToggle, DeathLink, Range, Choice, PerGameCommonOptions, OptionGroup # NOTE be aware that since the range of item ids that RoR2 uses is based off of the maximums of checks @@ -350,7 +350,7 @@ class ItemPoolPresetToggle(Toggle): class ItemWeights(Choice): - """Set item_pool_presets to true if you want to use one of these presets. + """Set Use Item Weight Presets to yes if you want to use one of these presets. Preset choices for determining the weights of the item pool. - New is a test for a potential adjustment to the default weights. - Uncommon puts a large number of uncommon items in the pool. @@ -375,6 +375,44 @@ class ItemWeights(Choice): option_void = 9 +ror2_option_groups = [ + OptionGroup("Explore Mode Options", [ + ChestsPerEnvironment, + ShrinesPerEnvironment, + ScavengersPerEnvironment, + ScannersPerEnvironment, + AltarsPerEnvironment, + RequireStages, + ProgressiveStages, + ]), + OptionGroup("Classic Mode Options", [ + TotalLocations, + ], start_collapsed=True), + OptionGroup("Weighted Choices", [ + ItemWeights, + ItemPoolPresetToggle, + WhiteScrap, + GreenScrap, + YellowScrap, + RedScrap, + CommonItem, + UncommonItem, + LegendaryItem, + BossItem, + LunarItem, + VoidItem, + Equipment, + Money, + LunarCoin, + Experience, + MountainTrap, + TimeWarpTrap, + CombatTrap, + TeleportTrap, + ]), +] + + @dataclass class ROR2Options(PerGameCommonOptions): goal: Goal @@ -399,10 +437,10 @@ class ROR2Options(PerGameCommonOptions): item_weights: ItemWeights item_pool_presets: ItemPoolPresetToggle # define the weights of the generated item pool. + white_scrap: WhiteScrap green_scrap: GreenScrap - red_scrap: RedScrap yellow_scrap: YellowScrap - white_scrap: WhiteScrap + red_scrap: RedScrap common_item: CommonItem uncommon_item: UncommonItem legendary_item: LegendaryItem diff --git a/worlds/ror2/regions.py b/worlds/ror2/regions.py index 199fdccf80e8..def29b47286b 100644 --- a/worlds/ror2/regions.py +++ b/worlds/ror2/regions.py @@ -19,11 +19,13 @@ def create_explore_regions(ror2_world: "RiskOfRainWorld") -> None: # Default Locations non_dlc_regions: Dict[str, RoRRegionData] = { "Menu": RoRRegionData(None, ["Distant Roost", "Distant Roost (2)", - "Titanic Plains", "Titanic Plains (2)"]), + "Titanic Plains", "Titanic Plains (2)", + "Verdant Falls"]), "Distant Roost": RoRRegionData([], ["OrderedStage_1"]), "Distant Roost (2)": RoRRegionData([], ["OrderedStage_1"]), "Titanic Plains": RoRRegionData([], ["OrderedStage_1"]), "Titanic Plains (2)": RoRRegionData([], ["OrderedStage_1"]), + "Verdant Falls": RoRRegionData([], ["OrderedStage_1"]), "Abandoned Aqueduct": RoRRegionData([], ["OrderedStage_2"]), "Wetland Aspect": RoRRegionData([], ["OrderedStage_2"]), "Rallypoint Delta": RoRRegionData([], ["OrderedStage_3"]), diff --git a/worlds/ror2/ror2environments.py b/worlds/ror2/ror2environments.py index d821763ef40c..61707b336241 100644 --- a/worlds/ror2/ror2environments.py +++ b/worlds/ror2/ror2environments.py @@ -7,6 +7,7 @@ "Distant Roost (2)": 8, # blackbeach2 "Titanic Plains": 15, # golemplains "Titanic Plains (2)": 16, # golemplains2 + "Verdant Falls": 28, # lakes } environment_vanilla_orderedstage_2_table: Dict[str, int] = { "Abandoned Aqueduct": 17, # goolake From 133167564c0f638d0b812b7765f40af3c0c85720 Mon Sep 17 00:00:00 2001 From: Justus Lind Date: Wed, 5 Jun 2024 05:45:26 +1000 Subject: [PATCH 07/36] Muse Dash: Option Groups and Options Rework (#3434) * Ensure that included/starter songs only include those within enabled dlcs. * Allow filtering traps by trap instead of by category. * Add in the currently available limited time dlcs to the dlc list. * Add the option group to the webhost and cleanup some errors. * Fix trap list. * Update tests. Add new ones to test correctness of new features. * Remove the old Just As Planned option * Make traps order alphabetically. Also adjust the title for traps. * Adjust new lines to better fit the website. * Style fixes. * Test adjustments and a fix due to test no longer having just as planned dlc. * Undo spacing changes as it breaks yaml generation. * Fix indenting in webhost. * Add the old options in as removed. Also clean up unused import. * Remove references to the old allow_just_as_planned_dlc_songs option in Muse Dash tests. * Add newline to end of file. --------- Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com> --- worlds/musedash/MuseDashCollection.py | 30 +++-- worlds/musedash/Options.py | 109 ++++++++++++------ worlds/musedash/Presets.py | 6 +- worlds/musedash/__init__.py | 39 +++---- worlds/musedash/test/TestCollection.py | 17 +-- worlds/musedash/test/TestDifficultyRanges.py | 24 ++-- worlds/musedash/test/TestPlandoSettings.py | 39 +++++-- worlds/musedash/test/TestTrapOption.py | 33 ++++++ worlds/musedash/test/TestWorstCaseSettings.py | 9 +- worlds/musedash/test/__init__.py | 7 +- 10 files changed, 209 insertions(+), 104 deletions(-) create mode 100644 worlds/musedash/test/TestTrapOption.py diff --git a/worlds/musedash/MuseDashCollection.py b/worlds/musedash/MuseDashCollection.py index 68e4ad5912bc..576a106df7cc 100644 --- a/worlds/musedash/MuseDashCollection.py +++ b/worlds/musedash/MuseDashCollection.py @@ -22,12 +22,15 @@ class MuseDashCollections: ] MUSE_PLUS_DLC: str = "Muse Plus" + + # Ordering matters for webhost. Order goes: Muse Plus, Time Limited Muse Plus Dlcs, Paid Dlcs DLC: List[str] = [ - # MUSE_PLUS_DLC, # To be included when OptionSets are rendered as part of basic settings. - # "maimai DX Limited-time Suite", # Part of Muse Plus. Goes away 31st Jan 2026. - "Miku in Museland", # Paid DLC not included in Muse Plus - "Rin Len's Mirrorland", # Paid DLC not included in Muse Plus - "MSR Anthology", # Now no longer available. + MUSE_PLUS_DLC, + "CHUNITHM COURSE MUSE", # Part of Muse Plus. Goes away 22nd May 2027. + "maimai DX Limited-time Suite", # Part of Muse Plus. Goes away 31st Jan 2026. + "MSR Anthology", # Now no longer available. + "Miku in Museland", # Paid DLC not included in Muse Plus + "Rin Len's Mirrorland", # Paid DLC not included in Muse Plus ] DIFF_OVERRIDES: List[str] = [ @@ -50,7 +53,7 @@ class MuseDashCollections: song_items: Dict[str, SongData] = {} song_locations: Dict[str, int] = {} - vfx_trap_items: Dict[str, int] = { + trap_items: Dict[str, int] = { "Bad Apple Trap": STARTING_CODE + 1, "Pixelate Trap": STARTING_CODE + 2, "Ripple Trap": STARTING_CODE + 3, @@ -58,14 +61,16 @@ class MuseDashCollections: "Chromatic Aberration Trap": STARTING_CODE + 5, "Background Freeze Trap": STARTING_CODE + 6, "Gray Scale Trap": STARTING_CODE + 7, - "Focus Line Trap": STARTING_CODE + 10, - } - - sfx_trap_items: Dict[str, int] = { "Nyaa SFX Trap": STARTING_CODE + 8, "Error SFX Trap": STARTING_CODE + 9, + "Focus Line Trap": STARTING_CODE + 10, } + sfx_trap_items: List[str] = [ + "Nyaa SFX Trap", + "Error SFX Trap", + ] + filler_items: Dict[str, int] = { "Great To Perfect (10 Pack)": STARTING_CODE + 30, "Miss To Great (5 Pack)": STARTING_CODE + 31, @@ -78,7 +83,7 @@ class MuseDashCollections: "Extra Life": 1, } - item_names_to_id: ChainMap = ChainMap({}, filler_items, sfx_trap_items, vfx_trap_items) + item_names_to_id: ChainMap = ChainMap({}, filler_items, trap_items) location_names_to_id: ChainMap = ChainMap(song_locations, album_locations) def __init__(self) -> None: @@ -171,6 +176,9 @@ def get_songs_with_settings(self, dlc_songs: Set[str], streamer_mode_active: boo return filtered_list + def filter_songs_to_dlc(self, song_list: List[str], dlc_songs: Set[str]) -> List[str]: + return [song for song in song_list if self.song_matches_dlc_filter(self.song_items[song], dlc_songs)] + def song_matches_dlc_filter(self, song: SongData, dlc_songs: Set[str]) -> bool: if song.album in self.FREE_ALBUMS: return True diff --git a/worlds/musedash/Options.py b/worlds/musedash/Options.py index 4f4f52ad2d2d..7164aa3e1362 100644 --- a/worlds/musedash/Options.py +++ b/worlds/musedash/Options.py @@ -1,26 +1,26 @@ -from typing import Dict -from Options import Toggle, Option, Range, Choice, DeathLink, ItemSet, OptionSet, PerGameCommonOptions +from Options import Toggle, Range, Choice, DeathLink, ItemSet, OptionSet, PerGameCommonOptions, OptionGroup, Removed from dataclasses import dataclass from .MuseDashCollection import MuseDashCollections -class AllowJustAsPlannedDLCSongs(Toggle): - """Whether [Muse Plus] DLC Songs, and all the albums included in it, can be chosen as randomised songs. - Note: The [Just As Planned] DLC contains all [Muse Plus] songs.""" - display_name = "Allow [Muse Plus] DLC Songs" - - class DLCMusicPacks(OptionSet): - """Which non-[Muse Plus] DLC packs can be chosen as randomised songs.""" + """ + Choose which DLC Packs will be included in the pool of chooseable songs. + + Note: The [Just As Planned] DLC contains all [Muse Plus] songs. + """ display_name = "DLC Packs" default = {} valid_keys = [dlc for dlc in MuseDashCollections.DLC] class StreamerModeEnabled(Toggle): - """In Muse Dash, an option named 'Streamer Mode' removes songs which may trigger copyright issues when streaming. - If this is enabled, only songs available under Streamer Mode will be available for randomization.""" + """ + In Muse Dash, an option named 'Streamer Mode' removes songs which may trigger copyright issues when streaming. + + If this is enabled, only songs available under Streamer Mode will be available for randomization. + """ display_name = "Streamer Mode Only Songs" @@ -33,7 +33,8 @@ class StartingSongs(Range): class AdditionalSongs(Range): - """The total number of songs that will be placed in the randomization pool. + """ + The total number of songs that will be placed in the randomization pool. - This does not count any starting songs or the goal song. - The final song count may be lower due to other settings. """ @@ -44,7 +45,8 @@ class AdditionalSongs(Range): class DifficultyMode(Choice): - """Ensures that at any chosen song has at least 1 value falling within these values. + """ + Ensures that at any chosen song has at least 1 value falling within these values. - Any: All songs are available - Easy: 1, 2 or 3 - Medium: 4, 5 @@ -66,8 +68,11 @@ class DifficultyMode(Choice): # Todo: Investigate options to make this non randomizable class DifficultyModeOverrideMin(Range): - """Ensures that 1 difficulty has at least 1 this value or higher per song. - - Difficulty Mode must be set to Manual.""" + """ + Ensures that 1 difficulty has at least 1 this value or higher per song. + + Note: Difficulty Mode must be set to Manual. + """ display_name = "Manual Difficulty Min" range_start = 1 range_end = 11 @@ -76,8 +81,11 @@ class DifficultyModeOverrideMin(Range): # Todo: Investigate options to make this non randomizable class DifficultyModeOverrideMax(Range): - """Ensures that 1 difficulty has at least 1 this value or lower per song. - - Difficulty Mode must be set to Manual.""" + """ + Ensures that 1 difficulty has at least 1 this value or lower per song. + + Note: Difficulty Mode must be set to Manual. + """ display_name = "Manual Difficulty Max" range_start = 1 range_end = 11 @@ -85,7 +93,8 @@ class DifficultyModeOverrideMax(Range): class GradeNeeded(Choice): - """Completing a song will require a grade of this value or higher in order to unlock items. + """ + Completing a song will require a grade of this value or higher in order to unlock items. The grades are as follows: - Silver S (SS): >= 95% accuracy - Pink S (S): >= 90% accuracy @@ -104,7 +113,9 @@ class GradeNeeded(Choice): class MusicSheetCountPercentage(Range): - """Controls how many music sheets are added to the pool based on the number of songs, including starting songs. + """ + Controls how many music sheets are added to the pool based on the number of songs, including starting songs. + Higher numbers leads to more consistent game lengths, but will cause individual music sheets to be less important. """ range_start = 10 @@ -121,19 +132,18 @@ class MusicSheetWinCountPercentage(Range): display_name = "Music Sheets Needed to Win" -class TrapTypes(Choice): - """This controls the types of traps that can be added to the pool. +class ChosenTraps(OptionSet): + """ + This controls the types of traps that can be added to the pool. + - Traps last the length of a song, or until you die. - VFX Traps consist of visual effects that play over the song. (i.e. Grayscale.) - SFX Traps consist of changing your sfx setting to one possibly more annoying sfx. - Traps last the length of a song, or until you die. + Note: SFX traps are only available if [Just as Planned] DLC songs are enabled. """ - display_name = "Available Trap Types" - option_None = 0 - option_VFX = 1 - option_SFX = 2 - option_All = 3 - default = 3 + display_name = "Chosen Traps" + default = {} + valid_keys = {trap for trap in MuseDashCollections.trap_items.keys()} class TrapCountPercentage(Range): @@ -145,24 +155,49 @@ class TrapCountPercentage(Range): class IncludeSongs(ItemSet): - """Any song listed here will be guaranteed to be included as part of the seed. - - Difficulty options will be skipped for these songs. - - If there being too many included songs, songs will be randomly chosen without regard for difficulty. - - If you want these songs immediately, use start_inventory instead. + """ + These songs will be guaranteed to show up within the seed. + - You must have the DLC enabled to play these songs. + - Difficulty options will not affect these songs. + - If there are too many included songs, this will act as a whitelist ignoring song difficulty. """ verify_item_name = True display_name = "Include Songs" class ExcludeSongs(ItemSet): - """Any song listed here will be excluded from being a part of the seed.""" + """ + These songs will be guaranteed to not show up within the seed. + + Note: Does not affect songs within the "Include Songs" list. + """ verify_item_name = True display_name = "Exclude Songs" +md_option_groups = [ + OptionGroup("Song Choice", [ + DLCMusicPacks, + StreamerModeEnabled, + IncludeSongs, + ExcludeSongs, + ]), + OptionGroup("Difficulty", [ + GradeNeeded, + DifficultyMode, + DifficultyModeOverrideMin, + DifficultyModeOverrideMax, + DeathLink, + ]), + OptionGroup("Traps", [ + ChosenTraps, + TrapCountPercentage, + ]), +] + + @dataclass class MuseDashOptions(PerGameCommonOptions): - allow_just_as_planned_dlc_songs: AllowJustAsPlannedDLCSongs dlc_packs: DLCMusicPacks streamer_mode_enabled: StreamerModeEnabled starting_song_count: StartingSongs @@ -173,8 +208,12 @@ class MuseDashOptions(PerGameCommonOptions): grade_needed: GradeNeeded music_sheet_count_percentage: MusicSheetCountPercentage music_sheet_win_count_percentage: MusicSheetWinCountPercentage - available_trap_types: TrapTypes + chosen_traps: ChosenTraps trap_count_percentage: TrapCountPercentage death_link: DeathLink include_songs: IncludeSongs exclude_songs: ExcludeSongs + + # Removed + allow_just_as_planned_dlc_songs: Removed + available_trap_types: Removed diff --git a/worlds/musedash/Presets.py b/worlds/musedash/Presets.py index 8dd8507d9b7f..fe314edbc9b5 100644 --- a/worlds/musedash/Presets.py +++ b/worlds/musedash/Presets.py @@ -3,7 +3,7 @@ MuseDashPresets: Dict[str, Dict[str, Any]] = { # An option to support Short Sync games. 40 songs. "No DLC - Short": { - "allow_just_as_planned_dlc_songs": False, + "dlc_packs": [], "starting_song_count": 5, "additional_song_count": 34, "music_sheet_count_percentage": 20, @@ -11,7 +11,7 @@ }, # An option to support Short Sync games but adds variety. 40 songs. "DLC - Short": { - "allow_just_as_planned_dlc_songs": True, + "dlc_packs": ["Muse Plus"], "starting_song_count": 5, "additional_song_count": 34, "music_sheet_count_percentage": 20, @@ -19,7 +19,7 @@ }, # An option to support Longer Sync/Async games. 100 songs. "DLC - Long": { - "allow_just_as_planned_dlc_songs": True, + "dlc_packs": ["Muse Plus"], "starting_song_count": 8, "additional_song_count": 91, "music_sheet_count_percentage": 20, diff --git a/worlds/musedash/__init__.py b/worlds/musedash/__init__.py index 1c009bfaee45..a9eacbbcf82c 100644 --- a/worlds/musedash/__init__.py +++ b/worlds/musedash/__init__.py @@ -1,10 +1,10 @@ from worlds.AutoWorld import World, WebWorld -from BaseClasses import Region, Item, ItemClassification, Entrance, Tutorial -from typing import List, ClassVar, Type +from BaseClasses import Region, Item, ItemClassification, Tutorial +from typing import List, ClassVar, Type, Set from math import floor from Options import PerGameCommonOptions -from .Options import MuseDashOptions +from .Options import MuseDashOptions, md_option_groups from .Items import MuseDashSongItem, MuseDashFixedItem from .Locations import MuseDashLocation from .MuseDashCollection import MuseDashCollections @@ -35,6 +35,7 @@ class MuseDashWebWorld(WebWorld): tutorials = [setup_en, setup_es] options_presets = MuseDashPresets + option_groups = md_option_groups class MuseDashWorld(World): @@ -72,8 +73,6 @@ class MuseDashWorld(World): def generate_early(self): dlc_songs = {key for key in self.options.dlc_packs.value} - if self.options.allow_just_as_planned_dlc_songs.value: - dlc_songs.add(self.md_collection.MUSE_PLUS_DLC) streamer_mode = self.options.streamer_mode_enabled (lower_diff_threshold, higher_diff_threshold) = self.get_difficulty_range() @@ -88,7 +87,7 @@ def generate_early(self): available_song_keys = self.md_collection.get_songs_with_settings( dlc_songs, bool(streamer_mode.value), lower_diff_threshold, higher_diff_threshold) - available_song_keys = self.handle_plando(available_song_keys) + available_song_keys = self.handle_plando(available_song_keys, dlc_songs) count_needed_for_start = max(0, starter_song_count - len(self.starting_songs)) if len(available_song_keys) + len(self.included_songs) >= count_needed_for_start + 11: @@ -109,7 +108,7 @@ def generate_early(self): for song in self.starting_songs: self.multiworld.push_precollected(self.create_item(song)) - def handle_plando(self, available_song_keys: List[str]) -> List[str]: + def handle_plando(self, available_song_keys: List[str], dlc_songs: Set[str]) -> List[str]: song_items = self.md_collection.song_items start_items = self.options.start_inventory.value.keys() @@ -117,7 +116,9 @@ def handle_plando(self, available_song_keys: List[str]) -> List[str]: exclude_songs = self.options.exclude_songs.value 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] + self.included_songs = self.md_collection.filter_songs_to_dlc(self.included_songs, dlc_songs) return [s for s in available_song_keys if s not in start_items and s not in include_songs and s not in exclude_songs] @@ -148,7 +149,7 @@ def create_song_pool(self, available_song_keys: List[str]): self.victory_song_name = available_song_keys[chosen_song - included_song_count] del available_song_keys[chosen_song - included_song_count] - # Next, make sure the starting songs are fufilled + # Next, make sure the starting songs are fulfilled if len(self.starting_songs) < starting_song_count: for _ in range(len(self.starting_songs), starting_song_count): if len(available_song_keys) > 0: @@ -156,7 +157,7 @@ def create_song_pool(self, available_song_keys: List[str]): else: self.starting_songs.append(self.included_songs.pop()) - # Then attempt to fufill any remaining songs for interim songs + # Then attempt to fulfill any remaining songs for interim songs if len(self.included_songs) < additional_song_count: for _ in range(len(self.included_songs), self.options.additional_song_count): if len(available_song_keys) <= 0: @@ -174,11 +175,7 @@ def create_item(self, name: str) -> Item: if filler: return MuseDashFixedItem(name, ItemClassification.filler, filler, self.player) - trap = self.md_collection.vfx_trap_items.get(name) - if trap: - return MuseDashFixedItem(name, ItemClassification.trap, trap, self.player) - - trap = self.md_collection.sfx_trap_items.get(name) + trap = self.md_collection.trap_items.get(name) if trap: return MuseDashFixedItem(name, ItemClassification.trap, trap, self.player) @@ -286,17 +283,11 @@ def set_rules(self) -> None: state.has(self.md_collection.MUSIC_SHEET_NAME, self.player, self.get_music_sheet_win_count()) def get_available_traps(self) -> List[str]: - sfx_traps_available = self.options.allow_just_as_planned_dlc_songs.value - - trap_list = [] - if self.options.available_trap_types.value & 1 != 0: - trap_list += self.md_collection.vfx_trap_items.keys() - - # SFX options are only available under Just as Planned DLC. - if sfx_traps_available and self.options.available_trap_types.value & 2 != 0: - trap_list += self.md_collection.sfx_trap_items.keys() + full_trap_list = self.md_collection.trap_items.keys() + if self.md_collection.MUSE_PLUS_DLC not in self.options.dlc_packs.value: + full_trap_list = [trap for trap in full_trap_list if trap not in self.md_collection.sfx_trap_items] - return trap_list + return [trap for trap in full_trap_list if trap in self.options.chosen_traps.value] def get_trap_count(self) -> int: multiplier = self.options.trap_count_percentage.value / 100.0 diff --git a/worlds/musedash/test/TestCollection.py b/worlds/musedash/test/TestCollection.py index 48cb69e403ad..c8c2b39acb4d 100644 --- a/worlds/musedash/test/TestCollection.py +++ b/worlds/musedash/test/TestCollection.py @@ -9,25 +9,26 @@ def test_all_names_are_ascii(self) -> None: for name in collection.song_items.keys(): for c in name: # This is taken directly from OoT. Represents the generally excepted characters. - if (0x20 <= ord(c) < 0x7e): + if 0x20 <= ord(c) < 0x7e: continue bad_names.append(name) break - self.assertEqual(len(bad_names), 0, f"Muse Dash has {len(bad_names)} songs with non-ASCII characters.\n{bad_names}") + self.assertEqual(len(bad_names), 0, + f"Muse Dash has {len(bad_names)} songs with non-ASCII characters.\n{bad_names}") def test_ids_dont_change(self) -> None: collection = MuseDashCollections() - itemsBefore = {name: code for name, code in collection.item_names_to_id.items()} - locationsBefore = {name: code for name, code in collection.location_names_to_id.items()} + items_before = {name: code for name, code in collection.item_names_to_id.items()} + locations_before = {name: code for name, code in collection.location_names_to_id.items()} collection.__init__() - itemsAfter = {name: code for name, code in collection.item_names_to_id.items()} - locationsAfter = {name: code for name, code in collection.location_names_to_id.items()} + items_after = {name: code for name, code in collection.item_names_to_id.items()} + locations_after = {name: code for name, code in collection.location_names_to_id.items()} - self.assertDictEqual(itemsBefore, itemsAfter, "Item ID changed after secondary init.") - self.assertDictEqual(locationsBefore, locationsAfter, "Location ID changed after secondary init.") + self.assertDictEqual(items_before, items_after, "Item ID changed after secondary init.") + self.assertDictEqual(locations_before, locations_after, "Location ID changed after secondary init.") def test_free_dlc_included_in_base_songs(self) -> None: collection = MuseDashCollections() diff --git a/worlds/musedash/test/TestDifficultyRanges.py b/worlds/musedash/test/TestDifficultyRanges.py index 89214d3f0f88..a9c36985afae 100644 --- a/worlds/musedash/test/TestDifficultyRanges.py +++ b/worlds/musedash/test/TestDifficultyRanges.py @@ -3,31 +3,31 @@ class DifficultyRanges(MuseDashTestBase): def test_all_difficulty_ranges(self) -> None: - muse_dash_world = self.multiworld.worlds[1] + muse_dash_world = self.get_world() dlc_set = {x for x in muse_dash_world.md_collection.DLC} difficulty_choice = muse_dash_world.options.song_difficulty_mode difficulty_min = muse_dash_world.options.song_difficulty_min difficulty_max = muse_dash_world.options.song_difficulty_max - def test_range(inputRange, lower, upper): - self.assertEqual(inputRange[0], lower) - self.assertEqual(inputRange[1], upper) + def test_range(input_range, lower, upper): + self.assertEqual(input_range[0], lower) + self.assertEqual(input_range[1], upper) - songs = muse_dash_world.md_collection.get_songs_with_settings(dlc_set, False, inputRange[0], inputRange[1]) + songs = muse_dash_world.md_collection.get_songs_with_settings(dlc_set, False, input_range[0], input_range[1]) for songKey in songs: song = muse_dash_world.md_collection.song_items[songKey] - if (song.easy is not None and inputRange[0] <= song.easy <= inputRange[1]): + if song.easy is not None and input_range[0] <= song.easy <= input_range[1]: continue - if (song.hard is not None and inputRange[0] <= song.hard <= inputRange[1]): + if song.hard is not None and input_range[0] <= song.hard <= input_range[1]: continue - if (song.master is not None and inputRange[0] <= song.master <= inputRange[1]): + if song.master is not None and input_range[0] <= song.master <= input_range[1]: continue - self.fail(f"Invalid song '{songKey}' was given for range '{inputRange[0]} to {inputRange[1]}'") + self.fail(f"Invalid song '{songKey}' was given for range '{input_range[0]} to {input_range[1]}'") - #auto ranges + # auto ranges difficulty_choice.value = 0 test_range(muse_dash_world.get_difficulty_range(), 0, 12) difficulty_choice.value = 1 @@ -61,7 +61,7 @@ def test_range(inputRange, lower, upper): test_range(muse_dash_world.get_difficulty_range(), 4, 6) def test_songs_have_difficulty(self) -> None: - muse_dash_world = self.multiworld.worlds[1] + muse_dash_world = self.get_world() for song_name in muse_dash_world.md_collection.DIFF_OVERRIDES: song = muse_dash_world.md_collection.song_items[song_name] @@ -73,4 +73,4 @@ def test_songs_have_difficulty(self) -> None: f"Song '{song_name}' difficulty not set when it should be.") else: self.assertTrue(song.easy is not None and song.hard is not None and song.master is not None, - f"Song '{song_name}' difficulty not set when it should be.") + f"Song '{song_name}' difficulty not set when it should be.") diff --git a/worlds/musedash/test/TestPlandoSettings.py b/worlds/musedash/test/TestPlandoSettings.py index 4b23a4afa90a..2617b7a4e02c 100644 --- a/worlds/musedash/test/TestPlandoSettings.py +++ b/worlds/musedash/test/TestPlandoSettings.py @@ -4,7 +4,32 @@ class TestPlandoSettings(MuseDashTestBase): options = { "additional_song_count": 15, - "allow_just_as_planned_dlc_songs": True, + "dlc_packs": {"Muse Plus"}, + "include_songs": [ + "Lunatic", + "Out of Sense", + "Magic Knight Girl", + ] + } + + def test_included_songs_didnt_grow_item_count(self) -> None: + muse_dash_world = self.get_world() + self.assertEqual(len(muse_dash_world.included_songs), 15, "Logical songs size grew when it shouldn't.") + + def test_included_songs_plando(self) -> None: + muse_dash_world = self.get_world() + songs = muse_dash_world.included_songs.copy() + songs.append(muse_dash_world.victory_song_name) + + self.assertIn("Lunatic", songs, "Logical songs is missing a plando song: Lunatic") + self.assertIn("Out of Sense", songs, "Logical songs is missing a plando song: Out of Sense") + self.assertIn("Magic Knight Girl", songs, "Logical songs is missing a plando song: Magic Knight Girl") + + +class TestFilteredPlandoSettings(MuseDashTestBase): + options = { + "additional_song_count": 15, + "dlc_packs": {"MSR Anthology"}, "include_songs": [ "Operation Blade", "Autumn Moods", @@ -13,15 +38,15 @@ class TestPlandoSettings(MuseDashTestBase): } def test_included_songs_didnt_grow_item_count(self) -> None: - muse_dash_world = self.multiworld.worlds[1] - self.assertEqual(len(muse_dash_world.included_songs), 15, - f"Logical songs size grew when it shouldn't. Expected 15. Got {len(muse_dash_world.included_songs)}") + muse_dash_world = self.get_world() + self.assertEqual(len(muse_dash_world.included_songs), 15, "Logical songs size grew when it shouldn't.") - def test_included_songs_plando(self) -> None: - muse_dash_world = self.multiworld.worlds[1] + # Tests for excluding included songs when the right dlc isn't enabled + def test_filtered_included_songs_plando(self) -> None: + muse_dash_world = self.get_world() songs = muse_dash_world.included_songs.copy() songs.append(muse_dash_world.victory_song_name) self.assertIn("Operation Blade", songs, "Logical songs is missing a plando song: Operation Blade") self.assertIn("Autumn Moods", songs, "Logical songs is missing a plando song: Autumn Moods") - self.assertIn("Fireflies", songs, "Logical songs is missing a plando song: Fireflies") \ No newline at end of file + self.assertNotIn("Fireflies", songs, "Logical songs has added a filtered a plando song: Fireflies") diff --git a/worlds/musedash/test/TestTrapOption.py b/worlds/musedash/test/TestTrapOption.py new file mode 100644 index 000000000000..ca0579c1f66c --- /dev/null +++ b/worlds/musedash/test/TestTrapOption.py @@ -0,0 +1,33 @@ +from . import MuseDashTestBase + + +class TestNoTraps(MuseDashTestBase): + def test_no_traps(self) -> None: + md_world = self.get_world() + md_world.options.chosen_traps.value.clear() + self.assertEqual(len(md_world.get_available_traps()), 0, "Got an available trap when we expected none.") + + def test_all_traps(self) -> None: + md_world = self.get_world() + md_world.options.dlc_packs.value.add(md_world.md_collection.MUSE_PLUS_DLC) + + for trap in md_world.md_collection.trap_items.keys(): + md_world.options.chosen_traps.value.add(trap) + + trap_count = len(md_world.get_available_traps()) + true_count = len(md_world.md_collection.trap_items.keys()) + + self.assertEqual(trap_count, true_count, "Got a different amount of traps than what was expected.") + + def test_exclude_sfx_traps(self) -> None: + md_world = self.get_world() + if "Muse Plus" in md_world.options.dlc_packs.value: + md_world.options.dlc_packs.value.remove("Muse Plus") + + for trap in md_world.md_collection.trap_items.keys(): + md_world.options.chosen_traps.value.add(trap) + + trap_count = len(md_world.get_available_traps()) + true_count = len(md_world.md_collection.trap_items.keys()) - len(md_world.md_collection.sfx_trap_items) + + self.assertEqual(trap_count, true_count, "Got a different amount of traps than what was expected.") diff --git a/worlds/musedash/test/TestWorstCaseSettings.py b/worlds/musedash/test/TestWorstCaseSettings.py index eeedfa5c3a5f..fd39651d1203 100644 --- a/worlds/musedash/test/TestWorstCaseSettings.py +++ b/worlds/musedash/test/TestWorstCaseSettings.py @@ -4,30 +4,33 @@ # This ends up with only 25 valid songs that can be chosen. # These tests ensure that this won't fail generation + class TestWorstCaseHighDifficulty(MuseDashTestBase): options = { "starting_song_count": 10, - "allow_just_as_planned_dlc_songs": False, + "dlc_packs": [], "streamer_mode_enabled": True, "song_difficulty_mode": 6, "song_difficulty_min": 11, "song_difficulty_max": 11, } + class TestWorstCaseMidDifficulty(MuseDashTestBase): options = { "starting_song_count": 10, - "allow_just_as_planned_dlc_songs": False, + "dlc_packs": [], "streamer_mode_enabled": True, "song_difficulty_mode": 6, "song_difficulty_min": 6, "song_difficulty_max": 6, } + class TestWorstCaseLowDifficulty(MuseDashTestBase): options = { "starting_song_count": 10, - "allow_just_as_planned_dlc_songs": False, + "dlc_packs": [], "streamer_mode_enabled": True, "song_difficulty_mode": 6, "song_difficulty_min": 1, diff --git a/worlds/musedash/test/__init__.py b/worlds/musedash/test/__init__.py index c77f9f6a06b8..ff9d988c65c2 100644 --- a/worlds/musedash/test/__init__.py +++ b/worlds/musedash/test/__init__.py @@ -1,5 +1,10 @@ from test.bases import WorldTestBase - +from .. import MuseDashWorld +from typing import cast class MuseDashTestBase(WorldTestBase): game = "Muse Dash" + + def get_world(self) -> MuseDashWorld: + return cast(MuseDashWorld, self.multiworld.worlds[1]) + From 3cc391e9a12058b13bc76efc7a19a83f6de52d36 Mon Sep 17 00:00:00 2001 From: Aaron Wagener Date: Tue, 4 Jun 2024 14:52:07 -0500 Subject: [PATCH 08/36] Docs: Add detail on customizing the forced groups (#3371) * Docs: Fix incorrect assertion in option group docs and add detail on customizing the forced groups. * add docs for the visibility attribute * typos * review comments * missed one Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> * better wording Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> --------- Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> --- docs/options api.md | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/docs/options api.md b/docs/options api.md index 798e97781a85..aedd5d76aaa7 100644 --- a/docs/options api.md +++ b/docs/options api.md @@ -86,17 +86,29 @@ class ExampleWorld(World): ``` ### Option Groups -Options may be categorized into groups for display on the WebHost. Option groups are displayed alphabetically on the -player-options and weighted-options pages. Options without a group name are categorized into a generic "Game Options" -group. +Options may be categorized into groups for display on the WebHost. Option groups are displayed in the order specified +by your world on the player-options and weighted-options pages. In the generated template files, there will be a comment +with the group name at the beginning of each group of options. The `start_collapsed` Boolean only affects how the groups +appear on the WebHost, with the grouping being collapsed when this is `True`. + +Options without a group name are categorized into a generic "Game Options" group, which is always the first group. If +every option for your world is in a group, this group will be removed. There is also an "Items & Location Options" +group, which is automatically created using certain specified `item_and_loc_options`. These specified options cannot be +removed from this group. + +Both the "Game Options" and "Item & Location Options" groups can be overridden by creating your own groups with +those names, letting you add options to them and change whether they start collapsed. The "Item & +Location Options" group can also be moved to a different position in the group ordering, but "Game Options" will always +be first, regardless of where it is in your list. ```python from worlds.AutoWorld import WebWorld from Options import OptionGroup +from . import Options class MyWorldWeb(WebWorld): option_groups = [ - OptionGroup('Color Options', [ + OptionGroup("Color Options", [ Options.ColorblindMode, Options.FlashReduction, Options.UIColors, From 76266f25efaede98e388c90427bcfcd15b04cbec Mon Sep 17 00:00:00 2001 From: Doug Hoskisson Date: Tue, 4 Jun 2024 16:54:21 -0700 Subject: [PATCH 09/36] Core: Launcher: can drag-and-drop patch on Launcher window (#3442) * Core: Launcher: can drag-and-drop patch on Launcher window * doc string for `_on_drop_file` --- Launcher.py | 11 +++++++++++ typings/kivy/core/window.pyi | 15 +++++++++++++++ typings/kivy/event.pyi | 2 ++ 3 files changed, 28 insertions(+) create mode 100644 typings/kivy/core/window.pyi create mode 100644 typings/kivy/event.pyi diff --git a/Launcher.py b/Launcher.py index 503ad5f8bd82..e26e4afc0f05 100644 --- a/Launcher.py +++ b/Launcher.py @@ -162,6 +162,7 @@ def launch(exe, in_terminal=False): def run_gui(): from kvui import App, ContainerLayout, GridLayout, Button, Label, ScrollBox, Widget + from kivy.core.window import Window from kivy.uix.image import AsyncImage from kivy.uix.relativelayout import RelativeLayout @@ -226,6 +227,8 @@ def build_button(component: Component) -> Widget: if client: client_layout.layout.add_widget(build_button(client[1])) + Window.bind(on_drop_file=self._on_drop_file) + return self.container @staticmethod @@ -235,6 +238,14 @@ def component_action(button): else: launch(get_exe(button.component), button.component.cli) + 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. """ + file, component = identify(filename.decode()) + if file and component: + run_component(component, file) + else: + logging.warning(f"unable to identify component for {filename}") + def _stop(self, *largs): # ran into what appears to be https://groups.google.com/g/kivy-users/c/saWDLoYCSZ4 with PyCharm. # Closing the window explicitly cleans it up. diff --git a/typings/kivy/core/window.pyi b/typings/kivy/core/window.pyi new file mode 100644 index 000000000000..c133b986d6c9 --- /dev/null +++ b/typings/kivy/core/window.pyi @@ -0,0 +1,15 @@ +from typing import Callable, ClassVar + +from kivy.event import EventDispatcher + + +class WindowBase(EventDispatcher): + width: ClassVar[int] # readonly AliasProperty + height: ClassVar[int] # readonly AliasProperty + + @staticmethod + def bind(**kwargs: Callable[..., None]) -> None: ... + + +class Window(WindowBase): + ... diff --git a/typings/kivy/event.pyi b/typings/kivy/event.pyi new file mode 100644 index 000000000000..2e76adab0baf --- /dev/null +++ b/typings/kivy/event.pyi @@ -0,0 +1,2 @@ +class EventDispatcher: + ... From 6a60a9309259db45af1437d4e0feb2a68d0ffee5 Mon Sep 17 00:00:00 2001 From: Doug Hoskisson Date: Tue, 4 Jun 2024 21:56:32 -0700 Subject: [PATCH 10/36] Zillion: fix some game over bugs (#3466) There was a bug that made lots of flashing terrain if a game over happened in certain places. (And this could be dangerous for people sensitive to flashing lights.) There was also a bug with a bad sound effect after a game over. --- worlds/zillion/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/worlds/zillion/requirements.txt b/worlds/zillion/requirements.txt index 3a784846a891..ae7d9b173308 100644 --- a/worlds/zillion/requirements.txt +++ b/worlds/zillion/requirements.txt @@ -1,2 +1,2 @@ -zilliandomizer @ git+https://github.com/beauxq/zilliandomizer@b36a23b5a138c78732ac8efb5b5ca8b0be07dcff#0.7.0 +zilliandomizer @ git+https://github.com/beauxq/zilliandomizer@1dd2ce01c9d818caba5844529699b3ad026d6a07#0.7.1 typing-extensions>=4.7, <5 From da2f0f94ca2ef8ecd0113018b7ff0664fba9289a Mon Sep 17 00:00:00 2001 From: qwint Date: Wed, 5 Jun 2024 00:01:22 -0500 Subject: [PATCH 11/36] HK: lower max egg cost (#3463) --- worlds/hk/Options.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/worlds/hk/Options.py b/worlds/hk/Options.py index f408528821cc..0ad1acff5df3 100644 --- a/worlds/hk/Options.py +++ b/worlds/hk/Options.py @@ -212,7 +212,7 @@ class MinimumEggPrice(Range): Only takes effect if the EggSlotShops option is greater than 0.""" display_name = "Minimum Egg Price" range_start = 1 - range_end = 21 + range_end = 20 default = 1 From afe4b2925e0b529b80a9adeff1f7c33b79de01c1 Mon Sep 17 00:00:00 2001 From: Fabian Dill Date: Wed, 5 Jun 2024 21:00:53 +0200 Subject: [PATCH 12/36] Setup: rename ArchipelagoLauncher(DEBUG) to ArchipelagoLauncherDebug (#3468) --- inno_setup.iss | 1 + setup.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/inno_setup.iss b/inno_setup.iss index b016f224dfcf..f44fd9a70983 100644 --- a/inno_setup.iss +++ b/inno_setup.iss @@ -87,6 +87,7 @@ Type: files; Name: "{app}\lib\worlds\_bizhawk.apworld" Type: files; Name: "{app}\ArchipelagoLttPClient.exe" Type: files; Name: "{app}\ArchipelagoPokemonClient.exe" Type: files; Name: "{app}\data\lua\connector_pkmn_rb.lua" +Type: files; Name: "{app}\ArchipelagoLauncher(DEBUG).exe" Type: filesandordirs; Name: "{app}\lib\worlds\rogue-legacy*" Type: filesandordirs; Name: "{app}\SNI\lua*" Type: filesandordirs; Name: "{app}\EnemizerCLI*" diff --git a/setup.py b/setup.py index 54d5118a2c50..85c0f9f7ff13 100644 --- a/setup.py +++ b/setup.py @@ -190,7 +190,7 @@ def resolve_icon(icon_name: str): c = next(component for component in components if component.script_name == "Launcher") exes.append(cx_Freeze.Executable( script=f"{c.script_name}.py", - target_name=f"{c.frozen_name}(DEBUG).exe", + target_name=f"{c.frozen_name}Debug.exe", icon=resolve_icon(c.icon), )) From 04ec2f38935063dc1d1857bb6160931fa58b975f Mon Sep 17 00:00:00 2001 From: Fabian Dill Date: Wed, 5 Jun 2024 22:26:13 +0200 Subject: [PATCH 13/36] Setup: delete old world folders (#3469) --- inno_setup.iss | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/inno_setup.iss b/inno_setup.iss index f44fd9a70983..7ae90622a1de 100644 --- a/inno_setup.iss +++ b/inno_setup.iss @@ -87,8 +87,11 @@ Type: files; Name: "{app}\lib\worlds\_bizhawk.apworld" Type: files; Name: "{app}\ArchipelagoLttPClient.exe" Type: files; Name: "{app}\ArchipelagoPokemonClient.exe" Type: files; Name: "{app}\data\lua\connector_pkmn_rb.lua" +Type: filesandordirs; Name: "{app}\lib\worlds\rogue-legacy" +Type: dirifempty; Name: "{app}\lib\worlds\rogue-legacy" +Type: filesandordirs; Name: "{app}\lib\worlds\bk_sudoku" +Type: dirifempty; Name: "{app}\lib\worlds\bk_sudoku" Type: files; Name: "{app}\ArchipelagoLauncher(DEBUG).exe" -Type: filesandordirs; Name: "{app}\lib\worlds\rogue-legacy*" Type: filesandordirs; Name: "{app}\SNI\lua*" Type: filesandordirs; Name: "{app}\EnemizerCLI*" #include "installdelete.iss" From be03dca77426e030422d8df9582af8cb85613923 Mon Sep 17 00:00:00 2001 From: Aaron Wagener Date: Wed, 5 Jun 2024 17:17:52 -0500 Subject: [PATCH 14/36] Core: add unit tests and more documentation for numeric options (#2926) * Core: add unit tests for the numeric options * document using a collection and the hashing quirk * add another example for the footgun --------- Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com> --- docs/options api.md | 19 +++++++- test/options/__init__.py | 0 test/options/test_option_classes.py | 67 +++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 test/options/__init__.py create mode 100644 test/options/test_option_classes.py diff --git a/docs/options api.md b/docs/options api.md index aedd5d76aaa7..cba383232b67 100644 --- a/docs/options api.md +++ b/docs/options api.md @@ -132,7 +132,8 @@ or if I need a boolean object, such as in my slot_data I can access it as: start_with_sword = bool(self.options.starting_sword.value) ``` All numeric options (i.e. Toggle, Choice, Range) can be compared to integers, strings that match their attributes, -strings that match the option attributes after "option_" is stripped, and the attributes themselves. +strings that match the option attributes after "option_" is stripped, and the attributes themselves. The option can +also be checked to see if it exists within a collection, but this will fail for a set of strings due to hashing. ```python # options.py class Logic(Choice): @@ -144,6 +145,12 @@ class Logic(Choice): alias_extra_hard = 2 crazy = 4 # won't be listed as an option and only exists as an attribute on the class +class Weapon(Choice): + option_none = 0 + option_sword = 1 + option_bow = 2 + option_hammer = 3 + # __init__.py from .options import Logic @@ -157,6 +164,16 @@ elif self.options.logic == Logic.option_extreme: do_extreme_things() elif self.options.logic == "crazy": do_insane_things() + +# check if the current option is in a collection of integers using the class attributes +if self.options.weapon in {Weapon.option_bow, Weapon.option_sword}: + do_stuff() +# in order to make a set of strings work, we have to compare against current_key +elif self.options.weapon.current_key in {"none", "hammer"}: + do_something_else() +# though it's usually better to just use a tuple instead +elif self.options.weapon in ("none", "hammer"): + do_something_else() ``` ## Generic Option Classes These options are generically available to every game automatically, but can be overridden for slightly different diff --git a/test/options/__init__.py b/test/options/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/test/options/test_option_classes.py b/test/options/test_option_classes.py new file mode 100644 index 000000000000..8e2c4702c380 --- /dev/null +++ b/test/options/test_option_classes.py @@ -0,0 +1,67 @@ +import unittest + +from Options import Choice, DefaultOnToggle, Toggle + + +class TestNumericOptions(unittest.TestCase): + def test_numeric_option(self) -> None: + """Tests the initialization and equivalency comparisons of the base Numeric Option class.""" + class TestChoice(Choice): + option_zero = 0 + option_one = 1 + option_two = 2 + alias_three = 1 + non_option_attr = 2 + + class TestToggle(Toggle): + pass + + class TestDefaultOnToggle(DefaultOnToggle): + pass + + with self.subTest("choice"): + choice_option_default = TestChoice.from_any(TestChoice.default) + choice_option_string = TestChoice.from_any("one") + choice_option_int = TestChoice.from_any(2) + choice_option_alias = TestChoice.from_any("three") + choice_option_attr = TestChoice.from_any(TestChoice.option_two) + + self.assertEqual(choice_option_default, TestChoice.option_zero, + "assigning default didn't match default value") + self.assertEqual(choice_option_string, "one") + self.assertEqual(choice_option_int, 2) + self.assertEqual(choice_option_alias, TestChoice.alias_three) + self.assertEqual(choice_option_attr, TestChoice.non_option_attr) + + self.assertRaises(KeyError, TestChoice.from_any, "four") + + self.assertIn(choice_option_int, [1, 2, 3]) + self.assertIn(choice_option_int, {2}) + self.assertIn(choice_option_int, (2,)) + + self.assertIn(choice_option_string, ["one", "two", "three"]) + # this fails since the hash is derived from the value + self.assertNotIn(choice_option_string, {"one"}) + self.assertIn(choice_option_string, ("one",)) + + with self.subTest("toggle"): + toggle_default = TestToggle.from_any(TestToggle.default) + toggle_string = TestToggle.from_any("false") + toggle_int = TestToggle.from_any(0) + toggle_alias = TestToggle.from_any("off") + + self.assertFalse(toggle_default) + self.assertFalse(toggle_string) + self.assertFalse(toggle_int) + self.assertFalse(toggle_alias) + + with self.subTest("on toggle"): + toggle_default = TestDefaultOnToggle.from_any(TestDefaultOnToggle.default) + toggle_string = TestDefaultOnToggle.from_any("true") + toggle_int = TestDefaultOnToggle.from_any(1) + toggle_alias = TestDefaultOnToggle.from_any("on") + + self.assertTrue(toggle_default) + self.assertTrue(toggle_string) + self.assertTrue(toggle_int) + self.assertTrue(toggle_alias) From c554c3fdaee6c30898ff2555d856deac7b734dc6 Mon Sep 17 00:00:00 2001 From: chandler05 <66492208+chandler05@users.noreply.github.com> Date: Wed, 5 Jun 2024 17:50:30 -0500 Subject: [PATCH 15/36] A Short Hike: Add new options and option groups (#3410) * A Short Hike: New options and stuff * Add to slot data for poptracker * Address concerns * Address concerns * Fix indentations * Update option description * Address all issues * Group "or"s --- worlds/shorthike/Items.py | 14 +- worlds/shorthike/Locations.py | 264 +++++++++++++++++----------------- worlds/shorthike/Options.py | 87 ++++++++++- worlds/shorthike/Rules.py | 44 +++++- worlds/shorthike/__init__.py | 61 +++++--- 5 files changed, 302 insertions(+), 168 deletions(-) diff --git a/worlds/shorthike/Items.py b/worlds/shorthike/Items.py index a240dcbc6a1f..7a5a81db9be6 100644 --- a/worlds/shorthike/Items.py +++ b/worlds/shorthike/Items.py @@ -10,15 +10,15 @@ class ItemDict(TypedDict): base_id = 82000 item_table: List[ItemDict] = [ - {"name": "Stick", "id": base_id + 1, "count": 8, "classification": ItemClassification.progression_skip_balancing}, + {"name": "Stick", "id": base_id + 1, "count": 0, "classification": ItemClassification.progression_skip_balancing}, {"name": "Seashell", "id": base_id + 2, "count": 23, "classification": ItemClassification.progression_skip_balancing}, {"name": "Golden Feather", "id": base_id + 3, "count": 0, "classification": ItemClassification.progression}, {"name": "Silver Feather", "id": base_id + 4, "count": 0, "classification": ItemClassification.useful}, {"name": "Bucket", "id": base_id + 5, "count": 0, "classification": ItemClassification.progression}, {"name": "Bait", "id": base_id + 6, "count": 2, "classification": ItemClassification.filler}, - {"name": "Fishing Rod", "id": base_id + 7, "count": 2, "classification": ItemClassification.progression}, + {"name": "Progressive Fishing Rod", "id": base_id + 7, "count": 2, "classification": ItemClassification.progression}, {"name": "Shovel", "id": base_id + 8, "count": 1, "classification": ItemClassification.progression}, - {"name": "Toy Shovel", "id": base_id + 9, "count": 5, "classification": ItemClassification.progression_skip_balancing}, + {"name": "Toy Shovel", "id": base_id + 9, "count": 0, "classification": ItemClassification.progression_skip_balancing}, {"name": "Compass", "id": base_id + 10, "count": 1, "classification": ItemClassification.useful}, {"name": "Medal", "id": base_id + 11, "count": 3, "classification": ItemClassification.filler}, {"name": "Shell Necklace", "id": base_id + 12, "count": 1, "classification": ItemClassification.progression}, @@ -36,7 +36,7 @@ class ItemDict(TypedDict): {"name": "Headband", "id": base_id + 24, "count": 1, "classification": ItemClassification.progression}, {"name": "Running Shoes", "id": base_id + 25, "count": 1, "classification": ItemClassification.useful}, {"name": "Camping Permit", "id": base_id + 26, "count": 1, "classification": ItemClassification.progression}, - {"name": "Walkie Talkie", "id": base_id + 27, "count": 1, "classification": ItemClassification.useful}, + {"name": "Walkie Talkie", "id": base_id + 27, "count": 0, "classification": ItemClassification.useful}, # Not in the item pool for now #{"name": "Boating Manual", "id": base_id + ~, "count": 1, "classification": ItemClassification.filler}, @@ -48,9 +48,9 @@ class ItemDict(TypedDict): {"name": "21 Coins", "id": base_id + 31, "count": 2, "classification": ItemClassification.filler}, {"name": "25 Coins", "id": base_id + 32, "count": 7, "classification": ItemClassification.filler}, {"name": "27 Coins", "id": base_id + 33, "count": 1, "classification": ItemClassification.filler}, - {"name": "32 Coins", "id": base_id + 34, "count": 1, "classification": ItemClassification.filler}, - {"name": "33 Coins", "id": base_id + 35, "count": 6, "classification": ItemClassification.filler}, - {"name": "50 Coins", "id": base_id + 36, "count": 1, "classification": ItemClassification.filler}, + {"name": "32 Coins", "id": base_id + 34, "count": 1, "classification": ItemClassification.useful}, + {"name": "33 Coins", "id": base_id + 35, "count": 6, "classification": ItemClassification.useful}, + {"name": "50 Coins", "id": base_id + 36, "count": 1, "classification": ItemClassification.useful}, # Filler item determined by settings {"name": "13 Coins", "id": base_id + 37, "count": 0, "classification": ItemClassification.filler}, diff --git a/worlds/shorthike/Locations.py b/worlds/shorthike/Locations.py index c2d316c68675..319ad8f20e1b 100644 --- a/worlds/shorthike/Locations.py +++ b/worlds/shorthike/Locations.py @@ -5,7 +5,7 @@ class LocationInfo(TypedDict): id: int inGameId: str needsShovel: bool - purchase: bool + purchase: int minGoldenFeathers: int minGoldenFeathersEasy: int minGoldenFeathersBucket: int @@ -17,311 +17,311 @@ class LocationInfo(TypedDict): {"name": "Start Beach Seashell", "id": base_id + 1, "inGameId": "PickUps.3", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Beach Hut Seashell", "id": base_id + 2, "inGameId": "PickUps.2", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Beach Umbrella Seashell", "id": base_id + 3, "inGameId": "PickUps.8", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Sid Beach Mound Seashell", "id": base_id + 4, "inGameId": "PickUps.12", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Sid Beach Seashell", "id": base_id + 5, "inGameId": "PickUps.11", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Shirley's Point Beach Seashell", "id": base_id + 6, "inGameId": "PickUps.18", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Shirley's Point Rock Seashell", "id": base_id + 7, "inGameId": "PickUps.17", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Visitor's Center Beach Seashell", "id": base_id + 8, "inGameId": "PickUps.19", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "West River Seashell", "id": base_id + 9, "inGameId": "PickUps.10", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "West Riverbank Seashell", "id": base_id + 10, "inGameId": "PickUps.4", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Stone Tower Riverbank Seashell", "id": base_id + 11, "inGameId": "PickUps.23", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "North Beach Seashell", "id": base_id + 12, "inGameId": "PickUps.6", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "North Coast Seashell", "id": base_id + 13, "inGameId": "PickUps.7", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Boat Cliff Seashell", "id": base_id + 14, "inGameId": "PickUps.14", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Boat Isle Mound Seashell", "id": base_id + 15, "inGameId": "PickUps.22", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "East Coast Seashell", "id": base_id + 16, "inGameId": "PickUps.21", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "House North Beach Seashell", "id": base_id + 17, "inGameId": "PickUps.16", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Airstream Island North Seashell", "id": base_id + 18, "inGameId": "PickUps.13", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Airstream Island South Seashell", "id": base_id + 19, "inGameId": "PickUps.15", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Secret Island Beach Seashell", "id": base_id + 20, "inGameId": "PickUps.1", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Meteor Lake Seashell", "id": base_id + 126, "inGameId": "PickUps.20", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Good Creek Path Seashell", "id": base_id + 127, "inGameId": "PickUps.9", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, # Visitor's Center Shop {"name": "Visitor's Center Shop Golden Feather 1", "id": base_id + 21, "inGameId": "CampRangerNPC[0]", - "needsShovel": False, "purchase": True, + "needsShovel": False, "purchase": 40, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Visitor's Center Shop Golden Feather 2", "id": base_id + 22, "inGameId": "CampRangerNPC[1]", - "needsShovel": False, "purchase": True, + "needsShovel": False, "purchase": 40, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Visitor's Center Shop Hat", "id": base_id + 23, "inGameId": "CampRangerNPC[9]", - "needsShovel": False, "purchase": True, + "needsShovel": False, "purchase": 100, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, # Tough Bird Salesman {"name": "Tough Bird Salesman Golden Feather 1", "id": base_id + 24, "inGameId": "ToughBirdNPC (1)[0]", - "needsShovel": False, "purchase": True, + "needsShovel": False, "purchase": 100, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Tough Bird Salesman Golden Feather 2", "id": base_id + 25, "inGameId": "ToughBirdNPC (1)[1]", - "needsShovel": False, "purchase": True, + "needsShovel": False, "purchase": 100, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Tough Bird Salesman Golden Feather 3", "id": base_id + 26, "inGameId": "ToughBirdNPC (1)[2]", - "needsShovel": False, "purchase": True, + "needsShovel": False, "purchase": 100, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Tough Bird Salesman Golden Feather 4", "id": base_id + 27, "inGameId": "ToughBirdNPC (1)[3]", - "needsShovel": False, "purchase": True, + "needsShovel": False, "purchase": 100, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Tough Bird Salesman (400 Coins)", "id": base_id + 28, "inGameId": "ToughBirdNPC (1)[9]", - "needsShovel": False, "purchase": True, + "needsShovel": False, "purchase": 400, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, # Beachstickball {"name": "Beachstickball (10 Hits)", "id": base_id + 29, "inGameId": "VolleyballOpponent[0]", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Beachstickball (20 Hits)", "id": base_id + 30, "inGameId": "VolleyballOpponent[1]", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Beachstickball (30 Hits)", "id": base_id + 31, "inGameId": "VolleyballOpponent[2]", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, # Misc Item Locations {"name": "Shovel Kid Trade", "id": base_id + 32, "inGameId": "Frog_StandingNPC[0]", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Compass Guy", "id": base_id + 33, "inGameId": "Fox_WalkingNPC[0]", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Hawk Peak Bucket Rock", "id": base_id + 34, "inGameId": "Tools.23", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Orange Islands Bucket Rock", "id": base_id + 35, "inGameId": "Tools.42", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Bill the Walrus Fisherman", "id": base_id + 36, "inGameId": "SittingNPC (1)[0]", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Catch 3 Fish Reward", "id": base_id + 37, "inGameId": "FishBuyer[0]", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Catch All Fish Reward", "id": base_id + 38, "inGameId": "FishBuyer[1]", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 7, "minGoldenFeathersEasy": 9, "minGoldenFeathersBucket": 7}, {"name": "Permit Guy Bribe", "id": base_id + 39, "inGameId": "CamperNPC[0]", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Catch Fish with Permit", "id": base_id + 129, "inGameId": "Player[0]", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Return Camping Permit", "id": base_id + 130, "inGameId": "CamperNPC[1]", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, # Original Pickaxe Locations {"name": "Blocked Mine Pickaxe 1", "id": base_id + 40, "inGameId": "Tools.31", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Blocked Mine Pickaxe 2", "id": base_id + 41, "inGameId": "Tools.32", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Blocked Mine Pickaxe 3", "id": base_id + 42, "inGameId": "Tools.33", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, # Original Toy Shovel Locations {"name": "Blackwood Trail Lookout Toy Shovel", "id": base_id + 43, "inGameId": "PickUps.27", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Shirley's Point Beach Toy Shovel", "id": base_id + 44, "inGameId": "PickUps.30", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Visitor's Center Beach Toy Shovel", "id": base_id + 45, "inGameId": "PickUps.29", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Blackwood Trail Rock Toy Shovel", "id": base_id + 46, "inGameId": "PickUps.26", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Beach Hut Cliff Toy Shovel", "id": base_id + 128, "inGameId": "PickUps.28", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, # Original Stick Locations {"name": "Secret Island Beach Trail Stick", "id": base_id + 47, "inGameId": "PickUps.25", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Below Lighthouse Walkway Stick", "id": base_id + 48, "inGameId": "Tools.3", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Beach Hut Rocky Pool Sand Stick", "id": base_id + 49, "inGameId": "Tools.0", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Cliff Overlooking West River Waterfall Stick", "id": base_id + 50, "inGameId": "Tools.2", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 2, "minGoldenFeathersBucket": 0}, {"name": "Trail to Tough Bird Salesman Stick", "id": base_id + 51, "inGameId": "Tools.8", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "North Beach Stick", "id": base_id + 52, "inGameId": "Tools.4", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Beachstickball Court Stick", "id": base_id + 53, "inGameId": "VolleyballMinigame.4", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Stick Under Sid Beach Umbrella", "id": base_id + 54, "inGameId": "Tools.1", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, # Boating @@ -333,377 +333,377 @@ class LocationInfo(TypedDict): {"name": "Boat Challenge Reward", "id": base_id + 56, "inGameId": "DeerKidBoat[0]", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, # Not a location for now, corresponding with the Boating Manual # {"name": "Receive Boating Manual", # "id": base_id + 133, # "inGameId": "DadDeer[1]", - # "needsShovel": False, "purchase": False, + # "needsShovel": False, "purchase": 0, # "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, # Original Map Locations {"name": "Outlook Point Dog Gift", "id": base_id + 57, "inGameId": "Dog_WalkingNPC_BlueEyed[0]", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, # Original Clothes Locations {"name": "Collect 15 Seashells", "id": base_id + 58, "inGameId": "LittleKidNPCVariant (1)[0]", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Return to Shell Kid", "id": base_id + 132, "inGameId": "LittleKidNPCVariant (1)[1]", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Taylor the Turtle Headband Gift", "id": base_id + 59, "inGameId": "Turtle_WalkingNPC[0]", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Sue the Rabbit Shoes Reward", "id": base_id + 60, "inGameId": "Bunny_WalkingNPC (1)[0]", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Purchase Sunhat", "id": base_id + 61, "inGameId": "SittingNPC[0]", - "needsShovel": False, "purchase": True, + "needsShovel": False, "purchase": 100, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, # Original Golden Feather Locations {"name": "Blackwood Forest Golden Feather", "id": base_id + 62, "inGameId": "Feathers.3", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Ranger May Shell Necklace Golden Feather", "id": base_id + 63, "inGameId": "AuntMayNPC[0]", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Sand Castle Golden Feather", "id": base_id + 64, "inGameId": "SandProvince.3", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Artist Golden Feather", "id": base_id + 65, "inGameId": "StandingNPC[0]", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Visitor Camp Rock Golden Feather", "id": base_id + 66, "inGameId": "Feathers.8", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Outlook Cliff Golden Feather", "id": base_id + 67, "inGameId": "Feathers.2", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Meteor Lake Cliff Golden Feather", "id": base_id + 68, "inGameId": "Feathers.7", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 5, "minGoldenFeathersBucket": 0}, # Original Silver Feather Locations {"name": "Secret Island Peak", "id": base_id + 69, "inGameId": "PickUps.24", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 5, "minGoldenFeathersEasy": 7, "minGoldenFeathersBucket": 7}, {"name": "Wristwatch Trade", "id": base_id + 70, "inGameId": "Goat_StandingNPC[0]", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, # Golden Chests {"name": "Lighthouse Golden Chest", "id": base_id + 71, "inGameId": "Feathers.0", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 2, "minGoldenFeathersEasy": 3, "minGoldenFeathersBucket": 0}, {"name": "Outlook Golden Chest", "id": base_id + 72, "inGameId": "Feathers.6", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Stone Tower Golden Chest", "id": base_id + 73, "inGameId": "Feathers.5", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "North Cliff Golden Chest", "id": base_id + 74, "inGameId": "Feathers.4", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 3, "minGoldenFeathersEasy": 10, "minGoldenFeathersBucket": 10}, # Chests {"name": "Blackwood Cliff Chest", "id": base_id + 75, "inGameId": "Coins.22", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "White Coast Trail Chest", "id": base_id + 76, "inGameId": "Coins.6", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Sid Beach Chest", "id": base_id + 77, "inGameId": "Coins.7", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Sid Beach Buried Treasure Chest", "id": base_id + 78, "inGameId": "Coins.46", - "needsShovel": True, "purchase": False, + "needsShovel": True, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Sid Beach Cliff Chest", "id": base_id + 79, "inGameId": "Coins.9", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Visitor's Center Buried Chest", "id": base_id + 80, "inGameId": "Coins.94", - "needsShovel": True, "purchase": False, + "needsShovel": True, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Visitor's Center Hidden Chest", "id": base_id + 81, "inGameId": "Coins.42", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Shirley's Point Chest", "id": base_id + 82, "inGameId": "Coins.10", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 1, "minGoldenFeathersEasy": 2, "minGoldenFeathersBucket": 2}, {"name": "Caravan Cliff Chest", "id": base_id + 83, "inGameId": "Coins.12", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Caravan Arch Chest", "id": base_id + 84, "inGameId": "Coins.11", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "King Buried Treasure Chest", "id": base_id + 85, "inGameId": "Coins.41", - "needsShovel": True, "purchase": False, + "needsShovel": True, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Good Creek Path Buried Chest", "id": base_id + 86, "inGameId": "Coins.48", - "needsShovel": True, "purchase": False, + "needsShovel": True, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Good Creek Path West Chest", "id": base_id + 87, "inGameId": "Coins.33", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Good Creek Path East Chest", "id": base_id + 88, "inGameId": "Coins.62", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "West Waterfall Chest", "id": base_id + 89, "inGameId": "Coins.20", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Stone Tower West Cliff Chest", "id": base_id + 90, "inGameId": "PickUps.0", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Bucket Path Chest", "id": base_id + 91, "inGameId": "Coins.50", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Bucket Cliff Chest", "id": base_id + 92, "inGameId": "Coins.49", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 3, "minGoldenFeathersEasy": 5, "minGoldenFeathersBucket": 5}, {"name": "In Her Shadow Buried Treasure Chest", "id": base_id + 93, "inGameId": "Feathers.9", - "needsShovel": True, "purchase": False, + "needsShovel": True, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Meteor Lake Buried Chest", "id": base_id + 94, "inGameId": "Coins.86", - "needsShovel": True, "purchase": False, + "needsShovel": True, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Meteor Lake Chest", "id": base_id + 95, "inGameId": "Coins.64", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "House North Beach Chest", "id": base_id + 96, "inGameId": "Coins.65", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "East Coast Chest", "id": base_id + 97, "inGameId": "Coins.98", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Fisherman's Boat Chest 1", "id": base_id + 99, "inGameId": "Boat.0", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Fisherman's Boat Chest 2", "id": base_id + 100, "inGameId": "Boat.7", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Airstream Island Chest", "id": base_id + 101, "inGameId": "Coins.31", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "West River Waterfall Head Chest", "id": base_id + 102, "inGameId": "Coins.34", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Old Building Chest", "id": base_id + 103, "inGameId": "Coins.104", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Old Building West Chest", "id": base_id + 104, "inGameId": "Coins.109", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Old Building East Chest", "id": base_id + 105, "inGameId": "Coins.8", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Hawk Peak West Chest", "id": base_id + 106, "inGameId": "Coins.21", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 3, "minGoldenFeathersEasy": 5, "minGoldenFeathersBucket": 5}, {"name": "Hawk Peak East Buried Chest", "id": base_id + 107, "inGameId": "Coins.76", - "needsShovel": True, "purchase": False, + "needsShovel": True, "purchase": 0, "minGoldenFeathers": 3, "minGoldenFeathersEasy": 5, "minGoldenFeathersBucket": 5}, {"name": "Hawk Peak Northeast Chest", "id": base_id + 108, "inGameId": "Coins.79", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 3, "minGoldenFeathersEasy": 5, "minGoldenFeathersBucket": 5}, {"name": "Northern East Coast Chest", "id": base_id + 109, "inGameId": "Coins.45", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 2, "minGoldenFeathersBucket": 0}, {"name": "North Coast Chest", "id": base_id + 110, "inGameId": "Coins.28", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "North Coast Buried Chest", "id": base_id + 111, "inGameId": "Coins.47", - "needsShovel": True, "purchase": False, + "needsShovel": True, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Small South Island Buried Chest", "id": base_id + 112, "inGameId": "Coins.87", - "needsShovel": True, "purchase": False, + "needsShovel": True, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Secret Island Bottom Chest", "id": base_id + 113, "inGameId": "Coins.88", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Secret Island Treehouse Chest", "id": base_id + 114, "inGameId": "Coins.89", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 1, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 1}, {"name": "Sunhat Island Buried Chest", "id": base_id + 115, "inGameId": "Coins.112", - "needsShovel": True, "purchase": False, + "needsShovel": True, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Orange Islands South Buried Chest", "id": base_id + 116, "inGameId": "Coins.119", - "needsShovel": True, "purchase": False, + "needsShovel": True, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Orange Islands West Chest", "id": base_id + 117, "inGameId": "Coins.121", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Orange Islands North Buried Chest", "id": base_id + 118, "inGameId": "Coins.117", - "needsShovel": True, "purchase": False, + "needsShovel": True, "purchase": 0, "minGoldenFeathers": 1, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Orange Islands East Chest", "id": base_id + 119, "inGameId": "Coins.120", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Orange Islands South Hidden Chest", "id": base_id + 120, "inGameId": "Coins.124", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "A Stormy View Buried Treasure Chest", "id": base_id + 121, "inGameId": "Coins.113", - "needsShovel": True, "purchase": False, + "needsShovel": True, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Orange Islands Ruins Buried Chest", "id": base_id + 122, "inGameId": "Coins.118", - "needsShovel": True, "purchase": False, + "needsShovel": True, "purchase": 0, "minGoldenFeathers": 2, "minGoldenFeathersEasy": 4, "minGoldenFeathersBucket": 0}, # Race Rewards {"name": "Lighthouse Race Reward", "id": base_id + 123, "inGameId": "RaceOpponent[0]", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 2, "minGoldenFeathersEasy": 3, "minGoldenFeathersBucket": 1}, {"name": "Old Building Race Reward", "id": base_id + 124, "inGameId": "RaceOpponent[1]", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 1, "minGoldenFeathersEasy": 5, "minGoldenFeathersBucket": 0}, {"name": "Hawk Peak Race Reward", "id": base_id + 125, "inGameId": "RaceOpponent[2]", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 7, "minGoldenFeathersEasy": 9, "minGoldenFeathersBucket": 7}, {"name": "Lose Race Gift", "id": base_id + 131, "inGameId": "RaceOpponent[9]", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, ] diff --git a/worlds/shorthike/Options.py b/worlds/shorthike/Options.py index 1ac0ff52f974..3d9bf81a3cf8 100644 --- a/worlds/shorthike/Options.py +++ b/worlds/shorthike/Options.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from Options import Choice, PerGameCommonOptions, Range, StartInventoryPool, Toggle +from Options import Choice, OptionGroup, PerGameCommonOptions, Range, StartInventoryPool, Toggle, DefaultOnToggle class Goal(Choice): """Choose the end goal. @@ -22,8 +22,10 @@ class CoinsInShops(Toggle): default = False class GoldenFeathers(Range): - """Number of Golden Feathers in the item pool. - (Note that for the Photo and Help Everyone goals, a minimum of 12 Golden Feathers is enforced)""" + """ + Number of Golden Feathers in the item pool. + (Note that for the Photo and Help Everyone goals, a minimum of 12 Golden Feathers is enforced) + """ display_name = "Golden Feathers" range_start = 0 range_end = 20 @@ -43,6 +45,20 @@ class Buckets(Range): range_end = 2 default = 2 +class Sticks(Range): + """Number of Sticks in the item pool.""" + display_name = "Sticks" + range_start = 1 + range_end = 8 + default = 8 + +class ToyShovels(Range): + """Number of Toy Shovels in the item pool.""" + display_name = "Toy Shovels" + range_start = 1 + range_end = 5 + default = 5 + class GoldenFeatherProgression(Choice): """Determines which locations are considered in logic based on the required amount of golden feathers to reach them. Easy: Locations will be considered inaccessible until the player has enough golden feathers to easily reach them. A minimum of 10 golden feathers is recommended for this setting. @@ -76,6 +92,40 @@ class FillerCoinAmount(Choice): option_50_coins = 9 default = 1 +class RandomWalkieTalkie(DefaultOnToggle): + """ + When enabled, the Walkie Talkie item will be placed into the item pool. Otherwise, it will be placed in its vanilla location. + This item usually allows the player to locate Avery around the map or restart a race. + """ + display_name = "Randomize Walkie Talkie" + +class EasierRaces(Toggle): + """When enabled, the Running Shoes will be added as a logical requirement for beating any of the races.""" + display_name = "Easier Races" + +class ShopCheckLogic(Choice): + """Determines which items will be added as logical requirements to making certain purchases in shops.""" + display_name = "Shop Check Logic" + option_nothing = 0 + option_fishing_rod = 1 + option_shovel = 2 + option_fishing_rod_and_shovel = 3 + option_golden_fishing_rod = 4 + option_golden_fishing_rod_and_shovel = 5 + default = 1 + +class MinShopCheckLogic(Choice): + """ + Determines the minimum cost of a shop item that will have the shop check logic applied to it. + If the cost of a shop item is less than this value, no items will be required to access it. + This is based on the vanilla prices of the shop item. The set cost multiplier will not affect this value. + """ + display_name = "Minimum Shop Check Logic Application" + option_40_coins = 0 + option_100_coins = 1 + option_400_coins = 2 + default = 1 + @dataclass class ShortHikeOptions(PerGameCommonOptions): start_inventory_from_pool: StartInventoryPool @@ -84,6 +134,37 @@ class ShortHikeOptions(PerGameCommonOptions): golden_feathers: GoldenFeathers silver_feathers: SilverFeathers buckets: Buckets + sticks: Sticks + toy_shovels: ToyShovels golden_feather_progression: GoldenFeatherProgression cost_multiplier: CostMultiplier filler_coin_amount: FillerCoinAmount + random_walkie_talkie: RandomWalkieTalkie + easier_races: EasierRaces + shop_check_logic: ShopCheckLogic + min_shop_check_logic: MinShopCheckLogic + +shorthike_option_groups = [ + OptionGroup("General Options", [ + Goal, + FillerCoinAmount, + RandomWalkieTalkie + ]), + OptionGroup("Logic Options", [ + GoldenFeatherProgression, + EasierRaces + ]), + OptionGroup("Item Pool Options", [ + GoldenFeathers, + SilverFeathers, + Buckets, + Sticks, + ToyShovels + ]), + OptionGroup("Shop Options", [ + CoinsInShops, + CostMultiplier, + ShopCheckLogic, + MinShopCheckLogic + ]) +] diff --git a/worlds/shorthike/Rules.py b/worlds/shorthike/Rules.py index 73a16434219e..4a71ebd3c80a 100644 --- a/worlds/shorthike/Rules.py +++ b/worlds/shorthike/Rules.py @@ -1,4 +1,5 @@ from worlds.generic.Rules import forbid_items_for_player, add_rule +from worlds.shorthike.Options import Goal, GoldenFeatherProgression, MinShopCheckLogic, ShopCheckLogic def create_rules(self, location_table): multiworld = self.multiworld @@ -11,11 +12,23 @@ def create_rules(self, location_table): forbid_items_for_player(multiworld.get_location(loc["name"], player), self.item_name_groups['Maps'], player) add_rule(multiworld.get_location(loc["name"], player), lambda state: state.has("Shovel", player)) + + # Shop Rules if loc["purchase"] and not options.coins_in_shops: forbid_items_for_player(multiworld.get_location(loc["name"], player), self.item_name_groups['Coins'], player) + if loc["purchase"] >= get_min_shop_logic_cost(self) and options.shop_check_logic != ShopCheckLogic.option_nothing: + if options.shop_check_logic in {ShopCheckLogic.option_fishing_rod, ShopCheckLogic.option_fishing_rod_and_shovel}: + add_rule(multiworld.get_location(loc["name"], player), + lambda state: state.has("Progressive Fishing Rod", player)) + if options.shop_check_logic in {ShopCheckLogic.option_golden_fishing_rod, ShopCheckLogic.option_golden_fishing_rod_and_shovel}: + add_rule(multiworld.get_location(loc["name"], player), + lambda state: state.has("Progressive Fishing Rod", player, 2)) + if options.shop_check_logic in {ShopCheckLogic.option_shovel, ShopCheckLogic.option_fishing_rod_and_shovel, ShopCheckLogic.option_golden_fishing_rod_and_shovel}: + add_rule(multiworld.get_location(loc["name"], player), + lambda state: state.has("Shovel", player)) # Minimum Feather Rules - if options.golden_feather_progression != 2: + if options.golden_feather_progression != GoldenFeatherProgression.option_hard: min_feathers = get_min_feathers(self, loc["minGoldenFeathers"], loc["minGoldenFeathersEasy"]) if options.buckets > 0 and loc["minGoldenFeathersBucket"] < min_feathers: @@ -32,11 +45,11 @@ def create_rules(self, location_table): # Fishing Rules add_rule(multiworld.get_location("Catch 3 Fish Reward", player), - lambda state: state.has("Fishing Rod", player)) + lambda state: state.has("Progressive Fishing Rod", player)) add_rule(multiworld.get_location("Catch Fish with Permit", player), - lambda state: state.has("Fishing Rod", player)) + lambda state: state.has("Progressive Fishing Rod", player)) add_rule(multiworld.get_location("Catch All Fish Reward", player), - lambda state: state.has("Fishing Rod", player)) + lambda state: state.has("Progressive Fishing Rod", player, 2)) # Misc Rules add_rule(multiworld.get_location("Return Camping Permit", player), @@ -59,15 +72,34 @@ def create_rules(self, location_table): lambda state: state.has("Stick", player)) add_rule(multiworld.get_location("Beachstickball (30 Hits)", player), lambda state: state.has("Stick", player)) + + # Race Rules + if options.easier_races: + add_rule(multiworld.get_location("Lighthouse Race Reward", player), + lambda state: state.has("Running Shoes", player)) + add_rule(multiworld.get_location("Old Building Race Reward", player), + lambda state: state.has("Running Shoes", player)) + add_rule(multiworld.get_location("Hawk Peak Race Reward", player), + lambda state: state.has("Running Shoes", player)) def get_min_feathers(self, min_golden_feathers, min_golden_feathers_easy): options = self.options min_feathers = min_golden_feathers - if options.golden_feather_progression == 0: + if options.golden_feather_progression == GoldenFeatherProgression.option_easy: min_feathers = min_golden_feathers_easy if min_feathers > options.golden_feathers: - if options.goal != 1 and options.goal != 3: + if options.goal not in {Goal.option_help_everyone, Goal.option_photo}: min_feathers = options.golden_feathers return min_feathers + +def get_min_shop_logic_cost(self): + options = self.options + + if options.min_shop_check_logic == MinShopCheckLogic.option_40_coins: + return 40 + elif options.min_shop_check_logic == MinShopCheckLogic.option_100_coins: + return 100 + elif options.min_shop_check_logic == MinShopCheckLogic.option_400_coins: + return 400 diff --git a/worlds/shorthike/__init__.py b/worlds/shorthike/__init__.py index 470b061c4bc0..299169a40c6b 100644 --- a/worlds/shorthike/__init__.py +++ b/worlds/shorthike/__init__.py @@ -1,12 +1,11 @@ -from collections import Counter from typing import ClassVar, Dict, Any, Type -from BaseClasses import Region, Location, Item, Tutorial +from BaseClasses import ItemClassification, Region, Location, Item, Tutorial from Options import PerGameCommonOptions from worlds.AutoWorld import World, WebWorld from .Items import item_table, group_table, base_id from .Locations import location_table from .Rules import create_rules, get_min_feathers -from .Options import ShortHikeOptions +from .Options import ShortHikeOptions, shorthike_option_groups class ShortHikeWeb(WebWorld): theme = "ocean" @@ -18,6 +17,7 @@ class ShortHikeWeb(WebWorld): "setup/en", ["Chandler"] )] + option_groups = shorthike_option_groups class ShortHikeWorld(World): """ @@ -47,9 +47,14 @@ def create_item(self, name: str) -> "ShortHikeItem": item_id: int = self.item_name_to_id[name] id = item_id - base_id - 1 - return ShortHikeItem(name, item_table[id]["classification"], item_id, player=self.player) + classification = item_table[id]["classification"] + if self.options.easier_races and name == "Running Shoes": + classification = ItemClassification.progression + + return ShortHikeItem(name, classification, item_id, player=self.player) def create_items(self) -> None: + itempool = [] for item in item_table: count = item["count"] @@ -57,18 +62,28 @@ def create_items(self) -> None: continue else: for i in range(count): - self.multiworld.itempool.append(self.create_item(item["name"])) + itempool.append(self.create_item(item["name"])) feather_count = self.options.golden_feathers if self.options.goal == 1 or self.options.goal == 3: if feather_count < 12: feather_count = 12 - junk = 45 - self.options.silver_feathers - feather_count - self.options.buckets - self.multiworld.itempool += [self.create_item(self.get_filler_item_name()) for _ in range(junk)] - self.multiworld.itempool += [self.create_item("Golden Feather") for _ in range(feather_count)] - self.multiworld.itempool += [self.create_item("Silver Feather") for _ in range(self.options.silver_feathers)] - self.multiworld.itempool += [self.create_item("Bucket") for _ in range(self.options.buckets)] + itempool += [self.create_item("Golden Feather") for _ in range(feather_count)] + itempool += [self.create_item("Silver Feather") for _ in range(self.options.silver_feathers)] + itempool += [self.create_item("Bucket") for _ in range(self.options.buckets)] + itempool += [self.create_item("Stick") for _ in range(self.options.sticks)] + itempool += [self.create_item("Toy Shovel") for _ in range(self.options.toy_shovels)] + + if self.options.random_walkie_talkie: + itempool.append(self.create_item("Walkie Talkie")) + else: + self.multiworld.get_location("Lose Race Gift", self.player).place_locked_item(self.create_item("Walkie Talkie")) + + junk = len(self.multiworld.get_unfilled_locations(self.player)) - len(itempool) + itempool += [self.create_item(self.get_filler_item_name()) for _ in range(junk)] + + self.multiworld.itempool += itempool def create_regions(self) -> None: menu_region = Region("Menu", self.player, self.multiworld) @@ -92,20 +107,23 @@ def create_regions(self) -> None: self.multiworld.completion_condition[self.player] = lambda state: state.has("Golden Feather", self.player, 12) elif self.options.goal == "races": # Races - self.multiworld.completion_condition[self.player] = lambda state: (state.has("Golden Feather", self.player, get_min_feathers(self, 7, 9)) - or (state.has("Bucket", self.player) and state.has("Golden Feather", self.player, 7))) + self.multiworld.completion_condition[self.player] = lambda state: state.can_reach_location("Hawk Peak Race Reward", self.player) elif self.options.goal == "help_everyone": # Help Everyone - self.multiworld.completion_condition[self.player] = lambda state: (state.has("Golden Feather", self.player, 12) - and state.has("Toy Shovel", self.player) and state.has("Camping Permit", self.player) - and state.has("Motorboat Key", self.player) and state.has("Headband", self.player) - and state.has("Wristwatch", self.player) and state.has("Seashell", self.player, 15) - and state.has("Shell Necklace", self.player)) + self.multiworld.completion_condition[self.player] = lambda state: (state.can_reach_location("Collect 15 Seashells", self.player) + and state.has("Golden Feather", self.player, 12) + and state.can_reach_location("Tough Bird Salesman (400 Coins)", self.player) + and state.can_reach_location("Ranger May Shell Necklace Golden Feather", self.player) + and state.can_reach_location("Sue the Rabbit Shoes Reward", self.player) + and state.can_reach_location("Wristwatch Trade", self.player) + and state.can_reach_location("Return Camping Permit", self.player) + and state.can_reach_location("Boat Challenge Reward", self.player) + and state.can_reach_location("Shovel Kid Trade", self.player) + and state.can_reach_location("Purchase Sunhat", self.player) + and state.can_reach_location("Artist Golden Feather", self.player)) elif self.options.goal == "fishmonger": # Fishmonger - self.multiworld.completion_condition[self.player] = lambda state: (state.has("Golden Feather", self.player, get_min_feathers(self, 7, 9)) - or (state.has("Bucket", self.player) and state.has("Golden Feather", self.player, 7)) - and state.has("Fishing Rod", self.player)) + self.multiworld.completion_condition[self.player] = lambda state: state.can_reach_location("Catch All Fish Reward", self.player) def set_rules(self): create_rules(self, location_table) @@ -117,6 +135,9 @@ def fill_slot_data(self) -> Dict[str, Any]: "goal": int(options.goal), "logicLevel": int(options.golden_feather_progression), "costMultiplier": int(options.cost_multiplier), + "shopCheckLogic": int(options.shop_check_logic), + "minShopCheckLogic": int(options.min_shop_check_logic), + "easierRaces": bool(options.easier_races), } slot_data = { From 93cd13736ae326a9812cf3782484e6c3b04bd3d3 Mon Sep 17 00:00:00 2001 From: Fabian Dill Date: Thu, 6 Jun 2024 01:36:02 +0200 Subject: [PATCH 16/36] Launcher: handle apworld installation (#3472) --- inno_setup.iss | 5 +++ worlds/LauncherComponents.py | 64 ++++++++++++++++++++++++++++++++++-- worlds/__init__.py | 6 +++- 3 files changed, 72 insertions(+), 3 deletions(-) diff --git a/inno_setup.iss b/inno_setup.iss index 7ae90622a1de..a0f4944d989f 100644 --- a/inno_setup.iss +++ b/inno_setup.iss @@ -213,6 +213,11 @@ Root: HKCR; Subkey: "{#MyAppName}multidata"; ValueData: "Arc Root: HKCR; Subkey: "{#MyAppName}multidata\DefaultIcon"; ValueData: "{app}\ArchipelagoServer.exe,0"; ValueType: string; ValueName: ""; Root: HKCR; Subkey: "{#MyAppName}multidata\shell\open\command"; ValueData: """{app}\ArchipelagoServer.exe"" ""%1"""; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: ".apworld"; ValueData: "{#MyAppName}worlddata"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}worlddata"; ValueData: "Archipelago World Data"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}worlddata\DefaultIcon"; ValueData: "{app}\ArchipelagoLauncher.exe,0"; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}worlddata\shell\open\command"; ValueData: """{app}\ArchipelagoLauncher.exe"" ""%1"""; + Root: HKCR; Subkey: "archipelago"; ValueType: "string"; ValueData: "Archipegalo Protocol"; Flags: uninsdeletekey; Root: HKCR; Subkey: "archipelago"; ValueType: "string"; ValueName: "URL Protocol"; ValueData: ""; Root: HKCR; Subkey: "archipelago\DefaultIcon"; ValueType: "string"; ValueData: "{app}\ArchipelagoTextClient.exe,0"; diff --git a/worlds/LauncherComponents.py b/worlds/LauncherComponents.py index 78ec14b4a4f5..890b41aafa63 100644 --- a/worlds/LauncherComponents.py +++ b/worlds/LauncherComponents.py @@ -1,8 +1,10 @@ +import logging +import pathlib import weakref from enum import Enum, auto -from typing import Optional, Callable, List, Iterable +from typing import Optional, Callable, List, Iterable, Tuple -from Utils import local_path +from Utils import local_path, open_filename class Type(Enum): @@ -49,8 +51,10 @@ def handles_file(self, path: str): def __repr__(self): return f"{self.__class__.__name__}({self.display_name})" + processes = weakref.WeakSet() + def launch_subprocess(func: Callable, name: str = None): global processes import multiprocessing @@ -58,6 +62,7 @@ def launch_subprocess(func: Callable, name: str = None): process.start() processes.add(process) + class SuffixIdentifier: suffixes: Iterable[str] @@ -77,6 +82,60 @@ def launch_textclient(): launch_subprocess(CommonClient.run_as_textclient, name="TextClient") +def _install_apworld(apworld_src: str = "") -> Optional[Tuple[pathlib.Path, pathlib.Path]]: + if not apworld_src: + apworld_src = open_filename('Select APWorld file to install', (('APWorld', ('.apworld',)),)) + if not apworld_src: + # user closed menu + return + + if not apworld_src.endswith(".apworld"): + raise Exception(f"Wrong file format, looking for .apworld. File identified: {apworld_src}") + + apworld_path = pathlib.Path(apworld_src) + + try: + import zipfile + zipfile.ZipFile(apworld_path).open(pathlib.Path(apworld_path.name).stem + "/__init__.py") + except ValueError as e: + raise Exception("Archive appears invalid or damaged.") from e + except KeyError as e: + raise Exception("Archive appears to not be an apworld. (missing __init__.py)") from e + + import worlds + if worlds.user_folder is None: + raise Exception("Custom Worlds directory appears to not be writable.") + for world_source in worlds.world_sources: + if apworld_path.samefile(world_source.resolved_path): + raise Exception(f"APWorld is already installed at {world_source.resolved_path}.") + + # TODO: run generic test suite over the apworld. + # TODO: have some kind of version system to tell from metadata if the apworld should be compatible. + + target = pathlib.Path(worlds.user_folder) / apworld_path.name + import shutil + shutil.copyfile(apworld_path, target) + + return apworld_path, target + + +def install_apworld(apworld_path: str = "") -> None: + try: + res = _install_apworld(apworld_path) + if res is None: + logging.info("Aborting APWorld installation.") + return + source, target = res + except Exception as e: + import Utils + Utils.messagebox(e.__class__.__name__, str(e), error=True) + logging.exception(e) + else: + import Utils + logging.info(f"Installed APWorld successfully, copied {source} to {target}.") + Utils.messagebox("Install complete.", f"Installed APWorld from {source}.") + + components: List[Component] = [ # Launcher Component('Launcher', 'Launcher', component_type=Type.HIDDEN), @@ -84,6 +143,7 @@ def launch_textclient(): Component('Host', 'MultiServer', 'ArchipelagoServer', cli=True, file_identifier=SuffixIdentifier('.archipelago', '.zip')), Component('Generate', 'Generate', cli=True), + Component("Install APWorld", func=install_apworld, file_identifier=SuffixIdentifier(".apworld")), Component('Text Client', 'CommonClient', 'ArchipelagoTextClient', func=launch_textclient), Component('Links Awakening DX Client', 'LinksAwakeningClient', file_identifier=SuffixIdentifier('.apladx')), diff --git a/worlds/__init__.py b/worlds/__init__.py index 09f72882195e..4da9d8e87c9e 100644 --- a/worlds/__init__.py +++ b/worlds/__init__.py @@ -10,7 +10,11 @@ from Utils import local_path, user_path local_folder = os.path.dirname(__file__) -user_folder = user_path("worlds") if user_path() != local_path() else None +user_folder = user_path("worlds") if user_path() != local_path() else user_path("custom_worlds") +try: + os.makedirs(user_folder, exist_ok=True) +except OSError: # can't access/write? + user_folder = None __all__ = { "network_data_package", From 911eba3202551fec5436fa4756b041e0a887ae97 Mon Sep 17 00:00:00 2001 From: black-sliver <59490463+black-sliver@users.noreply.github.com> Date: Thu, 6 Jun 2024 01:51:05 +0200 Subject: [PATCH 17/36] WebHost: update dependencies (#3476) --- WebHostLib/requirements.txt | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/WebHostLib/requirements.txt b/WebHostLib/requirements.txt index 62707d78cf1f..3452c9d416db 100644 --- a/WebHostLib/requirements.txt +++ b/WebHostLib/requirements.txt @@ -1,9 +1,10 @@ -flask>=3.0.0 +flask>=3.0.3 +werkzeug>=3.0.3 pony>=0.7.17 -waitress>=2.1.2 -Flask-Caching>=2.1.0 -Flask-Compress>=1.14 -Flask-Limiter>=3.5.0 +waitress>=3.0.0 +Flask-Caching>=2.3.0 +Flask-Compress>=1.15 +Flask-Limiter>=3.7.0 bokeh>=3.1.1; python_version <= '3.8' -bokeh>=3.3.2; python_version >= '3.9' -markupsafe>=2.1.3 +bokeh>=3.4.1; python_version >= '3.9' +markupsafe>=2.1.5 From afb6d9c4da0e1ff3017bd670713c55fbef8d792e Mon Sep 17 00:00:00 2001 From: black-sliver <59490463+black-sliver@users.noreply.github.com> Date: Thu, 6 Jun 2024 01:54:46 +0200 Subject: [PATCH 18/36] MultiServer, customserver, CI, Test: Fix problems in room hosting and test/simulate it (#3464) * Test: add hosting simulation test * WebHost: add weak typing to get_app() * MultiServer: add typing to auto_saver_thread * MultiServer: don't cancel task, properly end it * customserver: stop auto-save thread from saving after shutdown and make sure it stops, another potential memory leak * MultiServer, customserver: make datapackage small again * customserver: collect/finish room tasks Hopefully fixes the memory leak we are seeing * CI: test hosting * Test: hosting: verify autohoster saves on Ctrl+C * customserver: save when stopping via Ctrl+C --- .github/workflows/unittests.yml | 31 ++++- MultiServer.py | 18 ++- WebHost.py | 5 +- WebHostLib/customserver.py | 59 ++++++++-- test/hosting/__init__.py | 0 test/hosting/__main__.py | 191 ++++++++++++++++++++++++++++++ test/hosting/client.py | 110 +++++++++++++++++ test/hosting/generate.py | 75 ++++++++++++ test/hosting/serve.py | 115 ++++++++++++++++++ test/hosting/webhost.py | 201 ++++++++++++++++++++++++++++++++ test/hosting/world.py | 42 +++++++ 11 files changed, 828 insertions(+), 19 deletions(-) create mode 100644 test/hosting/__init__.py create mode 100644 test/hosting/__main__.py create mode 100644 test/hosting/client.py create mode 100644 test/hosting/generate.py create mode 100644 test/hosting/serve.py create mode 100644 test/hosting/webhost.py create mode 100644 test/hosting/world.py diff --git a/.github/workflows/unittests.yml b/.github/workflows/unittests.yml index b2530bd06c7d..3ad29b007772 100644 --- a/.github/workflows/unittests.yml +++ b/.github/workflows/unittests.yml @@ -24,7 +24,7 @@ on: - '.github/workflows/unittests.yml' jobs: - build: + unit: runs-on: ${{ matrix.os }} name: Test Python ${{ matrix.python.version }} ${{ matrix.os }} @@ -60,3 +60,32 @@ jobs: - name: Unittests run: | pytest -n auto + + hosting: + runs-on: ${{ matrix.os }} + name: Test hosting with ${{ matrix.python.version }} on ${{ matrix.os }} + + strategy: + matrix: + os: + - ubuntu-latest + python: + - {version: '3.11'} # current + + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python.version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python.version }} + - name: Install dependencies + run: | + python -m venv venv + source venv/bin/activate + python -m pip install --upgrade pip + python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt" + - name: Test hosting + run: | + source venv/bin/activate + export PYTHONPATH=$(pwd) + python test/hosting/__main__.py diff --git a/MultiServer.py b/MultiServer.py index 22375da2b3c5..dc5e3d21ac89 100644 --- a/MultiServer.py +++ b/MultiServer.py @@ -3,6 +3,7 @@ import argparse import asyncio import collections +import contextlib import copy import datetime import functools @@ -176,7 +177,7 @@ class Context: location_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]] all_item_and_group_names: typing.Dict[str, typing.Set[str]] all_location_and_group_names: typing.Dict[str, typing.Set[str]] - non_hintable_names: typing.Dict[str, typing.Set[str]] + non_hintable_names: typing.Dict[str, typing.AbstractSet[str]] spheres: typing.List[typing.Dict[int, typing.Set[int]]] """ each sphere is { player: { location_id, ... } } """ logger: logging.Logger @@ -231,7 +232,7 @@ def __init__(self, host: str, port: int, server_password: str, password: str, lo self.embedded_blacklist = {"host", "port"} self.client_ids: typing.Dict[typing.Tuple[int, int], datetime.datetime] = {} self.auto_save_interval = 60 # in seconds - self.auto_saver_thread = None + self.auto_saver_thread: typing.Optional[threading.Thread] = None self.save_dirty = False self.tags = ['AP'] self.games: typing.Dict[int, str] = {} @@ -268,6 +269,11 @@ def _load_game_data(self): for world_name, world in worlds.AutoWorldRegister.world_types.items(): self.non_hintable_names[world_name] = world.hint_blacklist + for game_package in self.gamespackage.values(): + # remove groups from data sent to clients + del game_package["item_name_groups"] + del game_package["location_name_groups"] + def _init_game_data(self): for game_name, game_package in self.gamespackage.items(): if "checksum" in game_package: @@ -1926,8 +1932,6 @@ def _cmd_status(self, tag: str = "") -> bool: def _cmd_exit(self) -> bool: """Shutdown the server""" self.ctx.server.ws_server.close() - if self.ctx.shutdown_task: - self.ctx.shutdown_task.cancel() self.ctx.exit_event.set() return True @@ -2285,7 +2289,8 @@ def parse_args() -> argparse.Namespace: async def auto_shutdown(ctx, to_cancel=None): - await asyncio.sleep(ctx.auto_shutdown) + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for(ctx.exit_event.wait(), ctx.auto_shutdown) def inactivity_shutdown(): ctx.server.ws_server.close() @@ -2305,7 +2310,8 @@ def inactivity_shutdown(): if seconds < 0: inactivity_shutdown() else: - await asyncio.sleep(seconds) + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for(ctx.exit_event.wait(), seconds) def load_server_cert(path: str, cert_key: typing.Optional[str]) -> "ssl.SSLContext": diff --git a/WebHost.py b/WebHost.py index 9b5edd322f91..afacd6288ec2 100644 --- a/WebHost.py +++ b/WebHost.py @@ -12,6 +12,9 @@ import Utils import settings +if typing.TYPE_CHECKING: + from flask import Flask + Utils.local_path.cached_path = os.path.dirname(__file__) or "." # py3.8 is not abs. remove "." when dropping 3.8 settings.no_gui = True configpath = os.path.abspath("config.yaml") @@ -19,7 +22,7 @@ configpath = os.path.abspath(Utils.user_path('config.yaml')) -def get_app(): +def get_app() -> "Flask": from WebHostLib import register, cache, app as raw_app from WebHostLib.models import db diff --git a/WebHostLib/customserver.py b/WebHostLib/customserver.py index 3a86cb551d27..9f70165b61e5 100644 --- a/WebHostLib/customserver.py +++ b/WebHostLib/customserver.py @@ -168,17 +168,28 @@ def get_random_port(): def get_static_server_data() -> dict: import worlds data = { - "non_hintable_names": {}, - "gamespackage": worlds.network_data_package["games"], - "item_name_groups": {world_name: world.item_name_groups for world_name, world in - worlds.AutoWorldRegister.world_types.items()}, - "location_name_groups": {world_name: world.location_name_groups for world_name, world in - worlds.AutoWorldRegister.world_types.items()}, + "non_hintable_names": { + world_name: world.hint_blacklist + for world_name, world in worlds.AutoWorldRegister.world_types.items() + }, + "gamespackage": { + world_name: { + key: value + for key, value in game_package.items() + if key not in ("item_name_groups", "location_name_groups") + } + for world_name, game_package in worlds.network_data_package["games"].items() + }, + "item_name_groups": { + world_name: world.item_name_groups + for world_name, world in worlds.AutoWorldRegister.world_types.items() + }, + "location_name_groups": { + world_name: world.location_name_groups + for world_name, world in worlds.AutoWorldRegister.world_types.items() + }, } - for world_name, world in worlds.AutoWorldRegister.world_types.items(): - data["non_hintable_names"][world_name] = world.hint_blacklist - return data @@ -266,12 +277,15 @@ async def start_room(room_id): ctx.logger.exception("Could not determine port. Likely hosting failure.") with db_session: ctx.auto_shutdown = Room.get(id=room_id).timeout + if ctx.saving: + setattr(asyncio.current_task(), "save", lambda: ctx._save(True)) ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, [])) await ctx.shutdown_task except (KeyboardInterrupt, SystemExit): if ctx.saving: ctx._save() + setattr(asyncio.current_task(), "save", None) except Exception as e: with db_session: room = Room.get(id=room_id) @@ -281,8 +295,12 @@ async def start_room(room_id): else: if ctx.saving: ctx._save() + setattr(asyncio.current_task(), "save", None) finally: try: + ctx.save_dirty = False # make sure the saving thread does not write to DB after final wakeup + ctx.exit_event.set() # make sure the saving thread stops at some point + # NOTE: async saving should probably be an async task and could be merged with shutdown_task with (db_session): # ensure the Room does not spin up again on its own, minute of safety buffer room = Room.get(id=room_id) @@ -294,13 +312,32 @@ async def start_room(room_id): rooms_shutting_down.put(room_id) class Starter(threading.Thread): + _tasks: typing.List[asyncio.Future] + + def __init__(self): + super().__init__() + self._tasks = [] + + def _done(self, task: asyncio.Future): + self._tasks.remove(task) + task.result() + def run(self): while 1: next_room = rooms_to_run.get(block=True, timeout=None) - asyncio.run_coroutine_threadsafe(start_room(next_room), loop) + task = asyncio.run_coroutine_threadsafe(start_room(next_room), loop) + self._tasks.append(task) + task.add_done_callback(self._done) logging.info(f"Starting room {next_room} on {name}.") starter = Starter() starter.daemon = True starter.start() - loop.run_forever() + try: + loop.run_forever() + finally: + # save all tasks that want to be saved during shutdown + for task in asyncio.all_tasks(loop): + save: typing.Optional[typing.Callable[[], typing.Any]] = getattr(task, "save", None) + if save: + save() diff --git a/test/hosting/__init__.py b/test/hosting/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/test/hosting/__main__.py b/test/hosting/__main__.py new file mode 100644 index 000000000000..6640c637b5bd --- /dev/null +++ b/test/hosting/__main__.py @@ -0,0 +1,191 @@ +# A bunch of tests to verify MultiServer and custom webhost server work as expected. +# This spawns processes and may modify your local AP, so this is not run as part of unit testing. +# Run with `python test/hosting` instead, +import logging +import traceback +from tempfile import TemporaryDirectory +from time import sleep +from typing import Any + +from test.hosting.client import Client +from test.hosting.generate import generate_local +from test.hosting.serve import ServeGame, LocalServeGame, WebHostServeGame +from test.hosting.webhost import (create_room, get_app, get_multidata_for_room, set_multidata_for_room, start_room, + stop_autohost, upload_multidata) +from test.hosting.world import copy as copy_world, delete as delete_world + +failure = False +fail_fast = True + + +def assert_true(condition: Any, msg: str = "") -> None: + global failure + if not condition: + failure = True + msg = f": {msg}" if msg else "" + raise AssertionError(f"Assertion failed{msg}") + + +def assert_equal(first: Any, second: Any, msg: str = "") -> None: + global failure + if first != second: + failure = True + msg = f": {msg}" if msg else "" + raise AssertionError(f"Assertion failed: {first} == {second}{msg}") + + +if fail_fast: + expect_true = assert_true + expect_equal = assert_equal +else: + def expect_true(condition: Any, msg: str = "") -> None: + global failure + if not condition: + failure = True + tb = "".join(traceback.format_stack()[:-1]) + msg = f": {msg}" if msg else "" + logging.error(f"Expectation failed{msg}\n{tb}") + + def expect_equal(first: Any, second: Any, msg: str = "") -> None: + global failure + if first != second: + failure = True + tb = "".join(traceback.format_stack()[:-1]) + msg = f": {msg}" if msg else "" + logging.error(f"Expectation failed {first} == {second}{msg}\n{tb}") + + +if __name__ == "__main__": + import warnings + warnings.simplefilter("ignore", ResourceWarning) + warnings.simplefilter("ignore", UserWarning) + + spacer = '=' * 80 + + with TemporaryDirectory() as tempdir: + multis = [["Clique"], ["Temp World"], ["Clique", "Temp World"]] + p1_games = [] + data_paths = [] + rooms = [] + + copy_world("Clique", "Temp World") + try: + for n, games in enumerate(multis, 1): + print(f"Generating [{n}] {', '.join(games)}") + multidata = generate_local(games, tempdir) + print(f"Generated [{n}] {', '.join(games)} as {multidata}\n") + p1_games.append(games[0]) + data_paths.append(multidata) + finally: + delete_world("Temp World") + + webapp = get_app(tempdir) + webhost_client = webapp.test_client() + for n, multidata in enumerate(data_paths, 1): + seed = upload_multidata(webhost_client, multidata) + room = create_room(webhost_client, seed) + print(f"Uploaded [{n}] {multidata} as {room}\n") + rooms.append(room) + + print("Starting autohost") + from WebHostLib.autolauncher import autohost + try: + autohost(webapp.config) + + host: ServeGame + for n, (multidata, room, game, multi_games) in enumerate(zip(data_paths, rooms, p1_games, multis), 1): + involved_games = {"Archipelago"} | set(multi_games) + for collected_items in range(3): + print(f"\nTesting [{n}] {game} in {multidata} on MultiServer with {collected_items} items collected") + with LocalServeGame(multidata) as host: + with Client(host.address, game, "Player1") as client: + local_data_packages = client.games_packages + local_collected_items = len(client.checked_locations) + if collected_items < 2: # Clique only has 2 Locations + client.collect_any() + # TODO: Ctrl+C test here as well + + for game_name in sorted(involved_games): + expect_true(game_name in local_data_packages, + f"{game_name} missing from MultiServer datap ackage") + expect_true("item_name_groups" not in local_data_packages.get(game_name, {}), + f"item_name_groups are not supposed to be in MultiServer data for {game_name}") + expect_true("location_name_groups" not in local_data_packages.get(game_name, {}), + f"location_name_groups are not supposed to be in MultiServer data for {game_name}") + for game_name in local_data_packages: + expect_true(game_name in involved_games, + f"Received unexpected extra data package for {game_name} from MultiServer") + assert_equal(local_collected_items, collected_items, + "MultiServer did not load or save correctly") + + print(f"\nTesting [{n}] {game} in {multidata} on customserver with {collected_items} items collected") + prev_host_adr: str + with WebHostServeGame(webhost_client, room) as host: + prev_host_adr = host.address + with Client(host.address, game, "Player1") as client: + web_data_packages = client.games_packages + web_collected_items = len(client.checked_locations) + if collected_items < 2: # Clique only has 2 Locations + client.collect_any() + if collected_items == 1: + sleep(1) # wait for the server to collect the item + stop_autohost(True) # simulate Ctrl+C + sleep(3) + autohost(webapp.config) # this will spin the room right up again + sleep(1) # make log less annoying + # if saving failed, the next iteration will fail below + + # verify server shut down + try: + with Client(prev_host_adr, game, "Player1") as client: + assert_true(False, "Server did not shut down") + except ConnectionError: + pass + + for game_name in sorted(involved_games): + expect_true(game_name in web_data_packages, + f"{game_name} missing from customserver data package") + expect_true("item_name_groups" not in web_data_packages.get(game_name, {}), + f"item_name_groups are not supposed to be in customserver data for {game_name}") + expect_true("location_name_groups" not in web_data_packages.get(game_name, {}), + f"location_name_groups are not supposed to be in customserver data for {game_name}") + for game_name in web_data_packages: + expect_true(game_name in involved_games, + f"Received unexpected extra data package for {game_name} from customserver") + assert_equal(web_collected_items, collected_items, + "customserver did not load or save correctly during/after " + + ("Ctrl+C" if collected_items == 2 else "/exit")) + + # compare customserver to MultiServer + expect_equal(local_data_packages, web_data_packages, + "customserver datapackage differs from MultiServer") + + sleep(5.5) # make sure all tasks actually stopped + + # raise an exception in customserver and verify the save doesn't get destroyed + # local variables room is the last room's id here + old_data = get_multidata_for_room(webhost_client, room) + print(f"Destroying multidata for {room}") + set_multidata_for_room(webhost_client, room, bytes([0])) + try: + start_room(webhost_client, room, timeout=7) + except TimeoutError: + pass + else: + assert_true(False, "Room started with destroyed multidata") + print(f"Restoring multidata for {room}") + set_multidata_for_room(webhost_client, room, old_data) + with WebHostServeGame(webhost_client, room) as host: + with Client(host.address, game, "Player1") as client: + assert_equal(len(client.checked_locations), 2, + "Save was destroyed during exception in customserver") + print("Save file is not busted 🥳") + + finally: + print("Stopping autohost") + stop_autohost(False) + + if failure: + print("Some tests failed") + exit(1) + exit(0) diff --git a/test/hosting/client.py b/test/hosting/client.py new file mode 100644 index 000000000000..b805bb6a2638 --- /dev/null +++ b/test/hosting/client.py @@ -0,0 +1,110 @@ +import json +import sys +from typing import Any, Collection, Dict, Iterable, Optional +from websockets import ConnectionClosed +from websockets.sync.client import connect, ClientConnection +from threading import Thread + + +__all__ = [ + "Client" +] + + +class Client: + """Incomplete, minimalistic sync test client for AP network protocol""" + + recv_timeout = 1.0 + + host: str + game: str + slot: str + password: Optional[str] + + _ws: Optional[ClientConnection] + + games: Iterable[str] + data_package_checksums: Dict[str, Any] + games_packages: Dict[str, Any] + missing_locations: Collection[int] + checked_locations: Collection[int] + + def __init__(self, host: str, game: str, slot: str, password: Optional[str] = None) -> None: + self.host = host + self.game = game + self.slot = slot + self.password = password + self._ws = None + self.games = [] + self.data_package_checksums = {} + self.games_packages = {} + self.missing_locations = [] + self.checked_locations = [] + + def __enter__(self) -> "Client": + try: + self.connect() + except BaseException: + self.__exit__(*sys.exc_info()) + raise + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: # type: ignore + self.close() + + def _poll(self) -> None: + assert self._ws + try: + while True: + self._ws.recv() + except (TimeoutError, ConnectionClosed, KeyboardInterrupt, SystemExit): + pass + + def connect(self) -> None: + self._ws = connect(f"ws://{self.host}") + room_info = json.loads(self._ws.recv(self.recv_timeout))[0] + self.games = sorted(room_info["games"]) + self.data_package_checksums = room_info["datapackage_checksums"] + self._ws.send(json.dumps([{ + "cmd": "GetDataPackage", + "games": list(self.games), + }])) + data_package_msg = json.loads(self._ws.recv(self.recv_timeout))[0] + self.games_packages = data_package_msg["data"]["games"] + self._ws.send(json.dumps([{ + "cmd": "Connect", + "game": self.game, + "name": self.slot, + "password": self.password, + "uuid": "", + "version": { + "class": "Version", + "major": 0, + "minor": 4, + "build": 6, + }, + "items_handling": 0, + "tags": [], + "slot_data": False, + }])) + connect_result_msg = json.loads(self._ws.recv(self.recv_timeout))[0] + if connect_result_msg["cmd"] != "Connected": + raise ConnectionError(", ".join(connect_result_msg.get("errors", [connect_result_msg["cmd"]]))) + self.missing_locations = connect_result_msg["missing_locations"] + self.checked_locations = connect_result_msg["checked_locations"] + + def close(self) -> None: + if self._ws: + Thread(target=self._poll).start() + self._ws.close() + + def collect(self, locations: Iterable[int]) -> None: + if not self._ws: + raise ValueError("Not connected") + self._ws.send(json.dumps([{ + "cmd": "LocationChecks", + "locations": locations, + }])) + + def collect_any(self) -> None: + self.collect([next(iter(self.missing_locations))]) diff --git a/test/hosting/generate.py b/test/hosting/generate.py new file mode 100644 index 000000000000..356cbcca25a0 --- /dev/null +++ b/test/hosting/generate.py @@ -0,0 +1,75 @@ +import json +import sys +import warnings +from pathlib import Path +from typing import Iterable, Union, TYPE_CHECKING + +if TYPE_CHECKING: + from multiprocessing.managers import ListProxy # noqa + +__all__ = [ + "generate_local", +] + + +def _generate_local_inner(games: Iterable[str], + dest: Union[Path, str], + results: "ListProxy[Union[Path, BaseException]]") -> None: + original_argv = sys.argv + warnings.simplefilter("ignore") + try: + from tempfile import TemporaryDirectory + + if not isinstance(dest, Path): + dest = Path(dest) + + with TemporaryDirectory() as players_dir: + with TemporaryDirectory() as output_dir: + import Generate + + for n, game in enumerate(games, 1): + player_path = Path(players_dir) / f"{n}.yaml" + with open(player_path, "w", encoding="utf-8") as f: + f.write(json.dumps({ + "name": f"Player{n}", + "game": game, + game: {"hard_mode": "true"}, + "description": f"generate_local slot {n} ('Player{n}'): {game}", + })) + + # this is basically copied from test/programs/test_generate.py + # uses a reproducible seed that is different for each set of games + sys.argv = [sys.argv[0], "--seed", str(hash(tuple(games))), + "--player_files_path", players_dir, + "--outputpath", output_dir] + Generate.main() + output_files = list(Path(output_dir).glob('*.zip')) + assert len(output_files) == 1 + final_file = dest / output_files[0].name + output_files[0].rename(final_file) + results.append(final_file) + except BaseException as e: + results.append(e) + raise e + finally: + sys.argv = original_argv + + +def generate_local(games: Iterable[str], dest: Union[Path, str]) -> Path: + from multiprocessing import Manager, Process, set_start_method + + try: + set_start_method("spawn") + except RuntimeError: + pass + + manager = Manager() + results: "ListProxy[Union[Path, Exception]]" = manager.list() + + p = Process(target=_generate_local_inner, args=(games, dest, results)) + p.start() + p.join() + result = results[0] + if isinstance(result, BaseException): + raise Exception("Could not generate multiworld") from result + return result diff --git a/test/hosting/serve.py b/test/hosting/serve.py new file mode 100644 index 000000000000..c3eaac87cc08 --- /dev/null +++ b/test/hosting/serve.py @@ -0,0 +1,115 @@ +import sys +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from threading import Event + from werkzeug.test import Client as FlaskClient + +__all__ = [ + "ServeGame", + "LocalServeGame", + "WebHostServeGame", +] + + +class ServeGame: + address: str + + +def _launch_multiserver(multidata: Path, ready: "Event", stop: "Event") -> None: + import os + import warnings + + original_argv = sys.argv + original_stdin = sys.stdin + warnings.simplefilter("ignore") + try: + import asyncio + from MultiServer import main, parse_args + + sys.argv = [sys.argv[0], str(multidata), "--host", "127.0.0.1"] + r, w = os.pipe() + sys.stdin = os.fdopen(r, "r") + + async def set_ready() -> None: + await asyncio.sleep(.01) # switch back to other task once more + ready.set() # server should be up, set ready state + + async def wait_stop() -> None: + await asyncio.get_event_loop().run_in_executor(None, stop.wait) + os.fdopen(w, "w").write("/exit") + + async def run() -> None: + # this will run main() until first await, then switch to set_ready() + await asyncio.gather( + main(parse_args()), + set_ready(), + wait_stop(), + ) + + asyncio.run(run()) + finally: + sys.argv = original_argv + sys.stdin = original_stdin + + +class LocalServeGame(ServeGame): + from multiprocessing import Process + + _multidata: Path + _proc: Process + _stop: "Event" + + def __init__(self, multidata: Path) -> None: + self.address = "" + self._multidata = multidata + + def __enter__(self) -> "LocalServeGame": + from multiprocessing import Manager, Process, set_start_method + + try: + set_start_method("spawn") + except RuntimeError: + pass + + manager = Manager() + ready: "Event" = manager.Event() + self._stop = manager.Event() + + self._proc = Process(target=_launch_multiserver, args=(self._multidata, ready, self._stop)) + try: + self._proc.start() + ready.wait(30) + self.address = "localhost:38281" + return self + except BaseException: + self.__exit__(*sys.exc_info()) + raise + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: # type: ignore + try: + self._stop.set() + self._proc.join(30) + except TimeoutError: + self._proc.terminate() + self._proc.join() + + +class WebHostServeGame(ServeGame): + _client: "FlaskClient" + _room: str + + def __init__(self, app_client: "FlaskClient", room: str) -> None: + self.address = "" + self._client = app_client + self._room = room + + def __enter__(self) -> "WebHostServeGame": + from .webhost import start_room + self.address = start_room(self._client, self._room) + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: # type: ignore + from .webhost import stop_room + stop_room(self._client, self._room, timeout=30) diff --git a/test/hosting/webhost.py b/test/hosting/webhost.py new file mode 100644 index 000000000000..e1e31ae466c4 --- /dev/null +++ b/test/hosting/webhost.py @@ -0,0 +1,201 @@ +import re +from pathlib import Path +from typing import TYPE_CHECKING, Optional, cast + +if TYPE_CHECKING: + from flask import Flask + from werkzeug.test import Client as FlaskClient + +__all__ = [ + "get_app", + "upload_multidata", + "create_room", + "start_room", + "stop_room", + "set_room_timeout", + "get_multidata_for_room", + "set_multidata_for_room", + "stop_autohost", +] + + +def get_app(tempdir: str) -> "Flask": + from WebHostLib import app as raw_app + from WebHost import get_app + raw_app.config["PONY"] = { + "provider": "sqlite", + "filename": str(Path(tempdir) / "host.db"), + "create_db": True, + } + raw_app.config.update({ + "TESTING": True, + "HOST_ADDRESS": "localhost", + "HOSTERS": 1, + }) + return get_app() + + +def upload_multidata(app_client: "FlaskClient", multidata: Path) -> str: + response = app_client.post("/uploads", data={ + "file": multidata.open("rb"), + }) + assert response.status_code < 400, f"Upload of {multidata} failed: status {response.status_code}" + assert "Location" in response.headers, f"Upload of {multidata} failed: no redirect" + location = response.headers["Location"] + assert isinstance(location, str) + assert location.startswith("/seed/"), f"Upload of {multidata} failed: unexpected redirect" + return location[6:] + + +def create_room(app_client: "FlaskClient", seed: str, auto_start: bool = False) -> str: + response = app_client.get(f"/new_room/{seed}") + assert response.status_code < 400, f"Creating room for {seed} failed: status {response.status_code}" + assert "Location" in response.headers, f"Creating room for {seed} failed: no redirect" + location = response.headers["Location"] + assert isinstance(location, str) + assert location.startswith("/room/"), f"Creating room for {seed} failed: unexpected redirect" + room_id = location[6:] + + if not auto_start: + # by default, creating a room will auto-start it, so we update last activity here + stop_room(app_client, room_id, simulate_idle=False) + + return room_id + + +def start_room(app_client: "FlaskClient", room_id: str, timeout: float = 30) -> str: + from time import sleep + + poll_interval = .2 + + print(f"Starting room {room_id}") + no_timeout = timeout <= 0 + while no_timeout or timeout > 0: + response = app_client.get(f"/room/{room_id}") + assert response.status_code == 200, f"Starting room for {room_id} failed: status {response.status_code}" + match = re.search(r"/connect ([\w:.\-]+)", response.text) + if match: + return match[1] + timeout -= poll_interval + sleep(poll_interval) + raise TimeoutError("Room did not start") + + +def stop_room(app_client: "FlaskClient", + room_id: str, + timeout: Optional[float] = None, + simulate_idle: bool = True) -> None: + from datetime import datetime, timedelta + from time import sleep + + from pony.orm import db_session + + from WebHostLib.models import Command, Room + from WebHostLib import app + + poll_interval = 2 + + print(f"Stopping room {room_id}") + room_uuid = app.url_map.converters["suuid"].to_python(None, room_id) # type: ignore[arg-type] + + if timeout is not None: + sleep(.1) # should not be required, but other things might use threading + + with db_session: + room: Room = Room.get(id=room_uuid) + if simulate_idle: + new_last_activity = datetime.utcnow() - timedelta(seconds=room.timeout + 5) + else: + new_last_activity = datetime.utcnow() - timedelta(days=3) + room.last_activity = new_last_activity + address = f"localhost:{room.last_port}" if room.last_port > 0 else None + if address: + original_timeout = room.timeout + room.timeout = 1 # avoid spinning it up again + Command(room=room, commandtext="/exit") + + try: + if address and timeout is not None: + print("waiting for shutdown") + import socket + host_str, port_str = tuple(address.split(":")) + address_tuple = host_str, int(port_str) + + no_timeout = timeout <= 0 + while no_timeout or timeout > 0: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + s.connect(address_tuple) + s.close() + except ConnectionRefusedError: + return + sleep(poll_interval) + timeout -= poll_interval + + raise TimeoutError("Room did not stop") + finally: + with db_session: + room = Room.get(id=room_uuid) + room.last_port = 0 # easier to detect when the host is up this way + if address: + room.timeout = original_timeout + room.last_activity = new_last_activity + print("timeout restored") + + +def set_room_timeout(room_id: str, timeout: float) -> None: + from pony.orm import db_session + + from WebHostLib.models import Room + from WebHostLib import app + + room_uuid = app.url_map.converters["suuid"].to_python(None, room_id) # type: ignore[arg-type] + with db_session: + room: Room = Room.get(id=room_uuid) + room.timeout = timeout + + +def get_multidata_for_room(webhost_client: "FlaskClient", room_id: str) -> bytes: + from pony.orm import db_session + + from WebHostLib.models import Room + from WebHostLib import app + + room_uuid = app.url_map.converters["suuid"].to_python(None, room_id) # type: ignore[arg-type] + with db_session: + room: Room = Room.get(id=room_uuid) + return cast(bytes, room.seed.multidata) + + +def set_multidata_for_room(webhost_client: "FlaskClient", room_id: str, data: bytes) -> None: + from pony.orm import db_session + + from WebHostLib.models import Room + from WebHostLib import app + + room_uuid = app.url_map.converters["suuid"].to_python(None, room_id) # type: ignore[arg-type] + with db_session: + room: Room = Room.get(id=room_uuid) + room.seed.multidata = data + + +def stop_autohost(graceful: bool = True) -> None: + import os + import signal + + import multiprocessing + + from WebHostLib.autolauncher import stop + + stop() + proc: multiprocessing.process.BaseProcess + for proc in filter(lambda child: child.name.startswith("MultiHoster"), multiprocessing.active_children()): + if graceful and proc.pid: + os.kill(proc.pid, getattr(signal, "CTRL_C_EVENT", signal.SIGINT)) + else: + proc.kill() + try: + proc.join(30) + except TimeoutError: + proc.kill() + proc.join() diff --git a/test/hosting/world.py b/test/hosting/world.py new file mode 100644 index 000000000000..e083e027fee1 --- /dev/null +++ b/test/hosting/world.py @@ -0,0 +1,42 @@ +import re +import shutil +from pathlib import Path +from typing import Dict + + +__all__ = ["copy", "delete"] + + +_new_worlds: Dict[str, str] = {} + + +def copy(src: str, dst: str) -> None: + from Utils import get_file_safe_name + from worlds import AutoWorldRegister + + assert dst not in _new_worlds, "World already created" + if '"' in dst or "\\" in dst: # easier to reject than to escape + raise ValueError(f"Unsupported symbols in {dst}") + dst_folder_name = get_file_safe_name(dst.lower()) + src_cls = AutoWorldRegister.world_types[src] + src_folder = Path(src_cls.__file__).parent + worlds_folder = src_folder.parent + if (not src_cls.__file__.endswith("__init__.py") or not src_folder.is_dir() + or not (worlds_folder / "generic").is_dir()): + raise ValueError(f"Unsupported layout for copy_world from {src}") + dst_folder = worlds_folder / dst_folder_name + if dst_folder.is_dir(): + raise ValueError(f"Destination {dst_folder} already exists") + shutil.copytree(src_folder, dst_folder) + _new_worlds[dst] = str(dst_folder) + with open(dst_folder / "__init__.py", "r", encoding="utf-8-sig") as f: + contents = f.read() + contents = re.sub(r'game\s*=\s*[\'"]' + re.escape(src) + r'[\'"]', f'game = "{dst}"', contents) + with open(dst_folder / "__init__.py", "w", encoding="utf-8") as f: + f.write(contents) + + +def delete(name: str) -> None: + assert name in _new_worlds, "World not created by this script" + shutil.rmtree(_new_worlds[name]) + del _new_worlds[name] From 86da3eb52c9cc41427650b3efe682996eb4d39a1 Mon Sep 17 00:00:00 2001 From: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com> Date: Thu, 6 Jun 2024 03:40:47 +0200 Subject: [PATCH 19/36] Remove all functools lru cache (#3446) --- worlds/witness/data/static_logic.py | 9 +++++---- worlds/witness/data/utils.py | 12 ++++++++---- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/worlds/witness/data/static_logic.py b/worlds/witness/data/static_logic.py index bae1921f6095..ecd95ea6c0fa 100644 --- a/worlds/witness/data/static_logic.py +++ b/worlds/witness/data/static_logic.py @@ -1,7 +1,8 @@ from collections import defaultdict -from functools import lru_cache from typing import Dict, List, Set, Tuple +from Utils import cache_argsless + from .item_definition_classes import ( CATEGORY_NAME_MAPPINGS, DoorItemDefinition, @@ -260,17 +261,17 @@ def get_parent_progressive_item(item_name: str) -> str: return _progressive_lookup.get(item_name, item_name) -@lru_cache +@cache_argsless def get_vanilla() -> StaticWitnessLogicObj: return StaticWitnessLogicObj(get_vanilla_logic()) -@lru_cache +@cache_argsless def get_sigma_normal() -> StaticWitnessLogicObj: return StaticWitnessLogicObj(get_sigma_normal_logic()) -@lru_cache +@cache_argsless def get_sigma_expert() -> StaticWitnessLogicObj: return StaticWitnessLogicObj(get_sigma_expert_logic()) diff --git a/worlds/witness/data/utils.py b/worlds/witness/data/utils.py index 5c5568b25661..2934308df3ec 100644 --- a/worlds/witness/data/utils.py +++ b/worlds/witness/data/utils.py @@ -1,4 +1,3 @@ -from functools import lru_cache from math import floor from pkgutil import get_data from random import random @@ -103,10 +102,15 @@ def parse_lambda(lambda_string) -> WitnessRule: return lambda_set -@lru_cache(maxsize=None) +_adjustment_file_cache = dict() + + def get_adjustment_file(adjustment_file: str) -> List[str]: - data = get_data(__name__, adjustment_file).decode("utf-8") - return [line.strip() for line in data.split("\n")] + if adjustment_file not in _adjustment_file_cache: + data = get_data(__name__, adjustment_file).decode("utf-8") + _adjustment_file_cache[adjustment_file] = [line.strip() for line in data.split("\n")] + + return _adjustment_file_cache[adjustment_file] def get_disable_unrandomized_list() -> List[str]: From 7f1e95c04ce08a905a3b9d1b511ec9fc79037f18 Mon Sep 17 00:00:00 2001 From: Doug Hoskisson Date: Thu, 6 Jun 2024 00:02:29 -0700 Subject: [PATCH 20/36] Core: gitignore custom_worlds (#3479) --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 022abe38fe40..0bba6f17264b 100644 --- a/.gitignore +++ b/.gitignore @@ -62,6 +62,7 @@ Output Logs/ /installdelete.iss /data/user.kv /datapackage +/custom_worlds # Byte-compiled / optimized / DLL files __pycache__/ From 808f2a8ff0de45ebda9f6bca3b2c0f7b54707dd0 Mon Sep 17 00:00:00 2001 From: black-sliver <59490463+black-sliver@users.noreply.github.com> Date: Thu, 6 Jun 2024 19:27:01 +0200 Subject: [PATCH 21/36] Core: update dependencies (#3477) --- requirements.txt | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/requirements.txt b/requirements.txt index d1a7b763f37f..db4f5445036a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,13 +2,13 @@ colorama>=0.4.6 websockets>=12.0 PyYAML>=6.0.1 jellyfish>=1.0.3 -jinja2>=3.1.3 -schema>=0.7.5 +jinja2>=3.1.4 +schema>=0.7.7 kivy>=2.3.0 bsdiff4>=1.2.4 -platformdirs>=4.1.0 -certifi>=2023.11.17 -cython>=3.0.8 +platformdirs>=4.2.2 +certifi>=2024.6.2 +cython>=3.0.10 cymem>=2.0.8 -orjson>=3.9.10 -typing_extensions>=4.7.0 +orjson>=3.10.3 +typing_extensions>=4.12.1 From 6bb1cce43f4f3dbbf489d46d7d3b6a14fd845b30 Mon Sep 17 00:00:00 2001 From: Doug Hoskisson Date: Thu, 6 Jun 2024 11:36:14 -0700 Subject: [PATCH 22/36] Core: hot reload components from installed apworld (#3480) * Core: hot reload components from installed apworld * address PR reviews `Launcher` widget members default to `None` so they can be defined in `build` `Launcher._refresh_components` is not wrapped loaded world goes into `world_sources` so we can check if it's already loaded. (`WorldSource` can be ordered now without trying to compare `None` and `float`) (don't load empty directories so we don't detect them as worlds) * clarify that the installation is successful --- Launcher.py | 71 ++++++++++++++++++++++++---------- typings/kivy/uix/boxlayout.pyi | 6 +++ typings/kivy/uix/layout.pyi | 8 +++- typings/schema/__init__.pyi | 17 ++++++++ worlds/LauncherComponents.py | 23 ++++++++++- worlds/__init__.py | 12 ++++-- 6 files changed, 110 insertions(+), 27 deletions(-) create mode 100644 typings/kivy/uix/boxlayout.pyi create mode 100644 typings/schema/__init__.pyi diff --git a/Launcher.py b/Launcher.py index e26e4afc0f05..e4b65be93a68 100644 --- a/Launcher.py +++ b/Launcher.py @@ -19,7 +19,7 @@ import webbrowser from os.path import isfile from shutil import which -from typing import Sequence, Union, Optional +from typing import Callable, Sequence, Union, Optional import Utils import settings @@ -160,6 +160,9 @@ def launch(exe, in_terminal=False): subprocess.Popen(exe) +refresh_components: Optional[Callable[[], None]] = None + + def run_gui(): from kvui import App, ContainerLayout, GridLayout, Button, Label, ScrollBox, Widget from kivy.core.window import Window @@ -170,11 +173,8 @@ class Launcher(App): base_title: str = "Archipelago Launcher" container: ContainerLayout grid: GridLayout - - _tools = {c.display_name: c for c in components if c.type == Type.TOOL} - _clients = {c.display_name: c for c in components if c.type == Type.CLIENT} - _adjusters = {c.display_name: c for c in components if c.type == Type.ADJUSTER} - _miscs = {c.display_name: c for c in components if c.type == Type.MISC} + _tool_layout: Optional[ScrollBox] = None + _client_layout: Optional[ScrollBox] = None def __init__(self, ctx=None): self.title = self.base_title @@ -182,18 +182,7 @@ def __init__(self, ctx=None): self.icon = r"data/icon.png" super().__init__() - def build(self): - self.container = ContainerLayout() - self.grid = GridLayout(cols=2) - self.container.add_widget(self.grid) - self.grid.add_widget(Label(text="General", size_hint_y=None, height=40)) - self.grid.add_widget(Label(text="Clients", size_hint_y=None, height=40)) - tool_layout = ScrollBox() - tool_layout.layout.orientation = "vertical" - self.grid.add_widget(tool_layout) - client_layout = ScrollBox() - client_layout.layout.orientation = "vertical" - self.grid.add_widget(client_layout) + def _refresh_components(self) -> None: def build_button(component: Component) -> Widget: """ @@ -218,14 +207,47 @@ def build_button(component: Component) -> Widget: return box_layout return button + # clear before repopulating + assert self._tool_layout and self._client_layout, "must call `build` first" + tool_children = reversed(self._tool_layout.layout.children) + for child in tool_children: + self._tool_layout.layout.remove_widget(child) + client_children = reversed(self._client_layout.layout.children) + for child in client_children: + self._client_layout.layout.remove_widget(child) + + _tools = {c.display_name: c for c in components if c.type == Type.TOOL} + _clients = {c.display_name: c for c in components if c.type == Type.CLIENT} + _adjusters = {c.display_name: c for c in components if c.type == Type.ADJUSTER} + _miscs = {c.display_name: c for c in components if c.type == Type.MISC} + for (tool, client) in itertools.zip_longest(itertools.chain( - self._tools.items(), self._miscs.items(), self._adjusters.items()), self._clients.items()): + _tools.items(), _miscs.items(), _adjusters.items() + ), _clients.items()): # column 1 if tool: - tool_layout.layout.add_widget(build_button(tool[1])) + self._tool_layout.layout.add_widget(build_button(tool[1])) # column 2 if client: - client_layout.layout.add_widget(build_button(client[1])) + self._client_layout.layout.add_widget(build_button(client[1])) + + def build(self): + self.container = ContainerLayout() + self.grid = GridLayout(cols=2) + self.container.add_widget(self.grid) + self.grid.add_widget(Label(text="General", size_hint_y=None, height=40)) + self.grid.add_widget(Label(text="Clients", size_hint_y=None, height=40)) + self._tool_layout = ScrollBox() + self._tool_layout.layout.orientation = "vertical" + self.grid.add_widget(self._tool_layout) + self._client_layout = ScrollBox() + self._client_layout.layout.orientation = "vertical" + self.grid.add_widget(self._client_layout) + + self._refresh_components() + + global refresh_components + refresh_components = self._refresh_components Window.bind(on_drop_file=self._on_drop_file) @@ -254,10 +276,17 @@ def _stop(self, *largs): Launcher().run() + # avoiding Launcher reference leak + # and don't try to do something with widgets after window closed + global refresh_components + refresh_components = None + def run_component(component: Component, *args): if component.func: component.func(*args) + if refresh_components: + refresh_components() elif component.script_name: subprocess.run([*get_exe(component.script_name), *args]) else: diff --git a/typings/kivy/uix/boxlayout.pyi b/typings/kivy/uix/boxlayout.pyi new file mode 100644 index 000000000000..c63d691debdd --- /dev/null +++ b/typings/kivy/uix/boxlayout.pyi @@ -0,0 +1,6 @@ +from typing import Literal +from .layout import Layout + + +class BoxLayout(Layout): + orientation: Literal['horizontal', 'vertical'] diff --git a/typings/kivy/uix/layout.pyi b/typings/kivy/uix/layout.pyi index 2a418a1d8b50..c27f89086306 100644 --- a/typings/kivy/uix/layout.pyi +++ b/typings/kivy/uix/layout.pyi @@ -1,8 +1,14 @@ -from typing import Any +from typing import Any, Sequence + from .widget import Widget class Layout(Widget): + @property + def children(self) -> Sequence[Widget]: ... + def add_widget(self, widget: Widget) -> None: ... + def remove_widget(self, widget: Widget) -> None: ... + def do_layout(self, *largs: Any, **kwargs: Any) -> None: ... diff --git a/typings/schema/__init__.pyi b/typings/schema/__init__.pyi new file mode 100644 index 000000000000..d993ec22745f --- /dev/null +++ b/typings/schema/__init__.pyi @@ -0,0 +1,17 @@ +from typing import Any, Callable + + +class And: + def __init__(self, __type: type, __func: Callable[[Any], bool]) -> None: ... + + +class Or: + def __init__(self, *args: object) -> None: ... + + +class Schema: + def __init__(self, __x: object) -> None: ... + + +class Optional(Schema): + ... diff --git a/worlds/LauncherComponents.py b/worlds/LauncherComponents.py index 890b41aafa63..18c1a1661ef0 100644 --- a/worlds/LauncherComponents.py +++ b/worlds/LauncherComponents.py @@ -1,3 +1,4 @@ +import bisect import logging import pathlib import weakref @@ -94,9 +95,10 @@ def _install_apworld(apworld_src: str = "") -> Optional[Tuple[pathlib.Path, path apworld_path = pathlib.Path(apworld_src) + module_name = pathlib.Path(apworld_path.name).stem try: import zipfile - zipfile.ZipFile(apworld_path).open(pathlib.Path(apworld_path.name).stem + "/__init__.py") + zipfile.ZipFile(apworld_path).open(module_name + "/__init__.py") except ValueError as e: raise Exception("Archive appears invalid or damaged.") from e except KeyError as e: @@ -107,6 +109,9 @@ def _install_apworld(apworld_src: str = "") -> Optional[Tuple[pathlib.Path, path raise Exception("Custom Worlds directory appears to not be writable.") for world_source in worlds.world_sources: if apworld_path.samefile(world_source.resolved_path): + # Note that this doesn't check if the same world is already installed. + # It only checks if the user is trying to install the apworld file + # that comes from the installation location (worlds or custom_worlds) raise Exception(f"APWorld is already installed at {world_source.resolved_path}.") # TODO: run generic test suite over the apworld. @@ -116,6 +121,22 @@ def _install_apworld(apworld_src: str = "") -> Optional[Tuple[pathlib.Path, path import shutil shutil.copyfile(apworld_path, target) + # If a module with this name is already loaded, then we can't load it now. + # TODO: We need to be able to unload a world module, + # so the user can update a world without restarting the application. + found_already_loaded = False + for loaded_world in worlds.world_sources: + loaded_name = pathlib.Path(loaded_world.path).stem + if module_name == loaded_name: + found_already_loaded = True + break + if found_already_loaded: + raise Exception(f"Installed APWorld successfully, but '{module_name}' is already loaded,\n" + "so a Launcher restart is required to use the new installation.") + world_source = worlds.WorldSource(str(target), is_zip=True) + bisect.insort(worlds.world_sources, world_source) + world_source.load() + return apworld_path, target diff --git a/worlds/__init__.py b/worlds/__init__.py index 4da9d8e87c9e..83ee96131aa2 100644 --- a/worlds/__init__.py +++ b/worlds/__init__.py @@ -1,11 +1,12 @@ import importlib +import logging import os import sys import warnings import zipimport import time import dataclasses -from typing import Dict, List, TypedDict, Optional +from typing import Dict, List, TypedDict from Utils import local_path, user_path @@ -48,7 +49,7 @@ class WorldSource: path: str # typically relative path from this module is_zip: bool = False relative: bool = True # relative to regular world import folder - time_taken: Optional[float] = None + time_taken: float = -1.0 def __repr__(self) -> str: return f"{self.__class__.__name__}({self.path}, is_zip={self.is_zip}, relative={self.relative})" @@ -92,7 +93,6 @@ def load(self) -> bool: print(f"Could not load world {self}:", file=file_like) traceback.print_exc(file=file_like) file_like.seek(0) - import logging logging.exception(file_like.read()) failed_world_loads.append(os.path.basename(self.path).rsplit(".", 1)[0]) return False @@ -107,7 +107,11 @@ def load(self) -> bool: if not entry.name.startswith(("_", ".")): file_name = entry.name if relative else os.path.join(folder, entry.name) if entry.is_dir(): - world_sources.append(WorldSource(file_name, relative=relative)) + init_file_path = os.path.join(entry.path, '__init__.py') + if os.path.isfile(init_file_path): + world_sources.append(WorldSource(file_name, relative=relative)) + else: + logging.warning(f"excluding {entry.name} from world sources because it has no __init__.py") elif entry.is_file() and entry.name.endswith(".apworld"): world_sources.append(WorldSource(file_name, is_zip=True, relative=relative)) From 31419c84a4bad41b8d6ea3bd109b206e8eb4f43d Mon Sep 17 00:00:00 2001 From: Scipio Wright Date: Thu, 6 Jun 2024 16:56:35 -0400 Subject: [PATCH 23/36] TUNIC: Remove rule for west Quarry bomb wall (#3481) * Update west quarry bomb wall rule * Update west quarry bomb wall rule --- worlds/tunic/er_rules.py | 2 -- worlds/tunic/rules.py | 2 -- 2 files changed, 4 deletions(-) diff --git a/worlds/tunic/er_rules.py b/worlds/tunic/er_rules.py index 08eb73a3b010..bbee212f5d5a 100644 --- a/worlds/tunic/er_rules.py +++ b/worlds/tunic/er_rules.py @@ -1462,8 +1462,6 @@ def set_er_location_rules(world: "TunicWorld", ability_unlocks: Dict[str, int]) # Quarry set_rule(multiworld.get_location("Quarry - [Central] Above Ladder Dash Chest", player), lambda state: state.has(laurels, player)) - set_rule(multiworld.get_location("Quarry - [West] Upper Area Bombable Wall", player), - lambda state: has_mask(state, player, options)) # Ziggurat set_rule(multiworld.get_location("Rooted Ziggurat Upper - Near Bridge Switch", player), diff --git a/worlds/tunic/rules.py b/worlds/tunic/rules.py index 0b65c8158e10..e0a2c305101b 100644 --- a/worlds/tunic/rules.py +++ b/worlds/tunic/rules.py @@ -304,8 +304,6 @@ def set_location_rules(world: "TunicWorld", ability_unlocks: Dict[str, int]) -> # Quarry set_rule(multiworld.get_location("Quarry - [Central] Above Ladder Dash Chest", player), lambda state: state.has(laurels, player)) - set_rule(multiworld.get_location("Quarry - [West] Upper Area Bombable Wall", player), - lambda state: has_mask(state, player, options)) # nmg - kill boss scav with orb + firecracker, or similar set_rule(multiworld.get_location("Rooted Ziggurat Lower - Hexagon Blue", player), lambda state: has_sword(state, player) or (state.has(grapple, player) and options.logic_rules)) From 223f2f55230e06b93acd63cbfeed739b545a44b1 Mon Sep 17 00:00:00 2001 From: chandler05 <66492208+chandler05@users.noreply.github.com> Date: Thu, 6 Jun 2024 15:57:50 -0500 Subject: [PATCH 24/36] A Short Hike: Update installation instructions (#3474) * A Short Hike: Update installation instructions * Update setup_en.md * Update setup_en.md * Change link --- worlds/shorthike/docs/setup_en.md | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/worlds/shorthike/docs/setup_en.md b/worlds/shorthike/docs/setup_en.md index 85d5a8f5eb16..96e4d8dbbd1d 100644 --- a/worlds/shorthike/docs/setup_en.md +++ b/worlds/shorthike/docs/setup_en.md @@ -4,7 +4,6 @@ - A Short Hike: [Steam](https://store.steampowered.com/app/1055540/A_Short_Hike/) - The Epic Games Store or itch.io version of A Short Hike will also work. -- A Short Hike Modding Tools: [GitHub](https://github.com/BrandenEK/AShortHike.ModdingTools) - A Short Hike Randomizer: [GitHub](https://github.com/BrandenEK/AShortHike.Randomizer) ## Optional Software @@ -14,18 +13,13 @@ ## Installation -1. Open the [Modding Tools GitHub page](https://github.com/BrandenEK/AShortHike.ModdingTools/), and follow -the installation instructions. After this step, your `A Short Hike/` folder should have an empty `Modding/` subfolder. - -2. After the Modding Tools have been installed, download the -[Randomizer](https://github.com/BrandenEK/AShortHike.Randomizer/releases) zip, extract it, and move the contents -of the `Randomizer/` folder into your `Modding/` folder. After this step, your `Modding/` folder should have - `data/` and `plugins/` subfolders. +Open the [Randomizer Repository](https://github.com/BrandenEK/AShortHike.Randomizer) and follow +the installation instructions listed there. ## Connecting A Short Hike will prompt you with the server details when a new game is started or a previous one is continued. -Enter in the Server Port, Name, and Password (optional) in the popup menu that appears and hit connect. +Enter in the Server Address and Port, Name, and Password (optional) in the popup menu that appears and hit connect. ## Tracking From d72afe71004420bcedde9c34a2d869a74314fe2f Mon Sep 17 00:00:00 2001 From: Silent <110704408+silent-destroyer@users.noreply.github.com> Date: Fri, 7 Jun 2024 11:45:22 -0400 Subject: [PATCH 25/36] Update setup_en.md (#3483) --- worlds/tunic/docs/setup_en.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/worlds/tunic/docs/setup_en.md b/worlds/tunic/docs/setup_en.md index 58cc1bcf25f2..f60506795af1 100644 --- a/worlds/tunic/docs/setup_en.md +++ b/worlds/tunic/docs/setup_en.md @@ -3,11 +3,12 @@ ## Required Software - [TUNIC](https://tunicgame.com/) for PC (Steam Deck also supported) -- [BepInEx (Unity IL2CPP)](https://github.com/BepInEx/BepInEx/releases/tag/v6.0.0-pre.1) - [TUNIC Randomizer Mod](https://github.com/silent-destroyer/tunic-randomizer/releases/latest) +- [BepInEx 6.0.0-pre.1 (Unity IL2CPP x64)](https://github.com/BepInEx/BepInEx/releases/tag/v6.0.0-pre.1) ## Optional Software -- [TUNIC Randomizer Map Tracker](https://github.com/SapphireSapphic/TunicTracker/releases/latest) (For use with EmoTracker/PopTracker) +- [TUNIC Randomizer Map Tracker](https://github.com/SapphireSapphic/TunicTracker/releases/latest) + - Requires [PopTracker](https://github.com/black-sliver/PopTracker/releases) - [TUNIC Randomizer Item Auto-tracker](https://github.com/radicoon/tunic-rando-tracker/releases/latest) - [Archipelago Text Client](https://github.com/ArchipelagoMW/Archipelago/releases/latest) @@ -27,7 +28,7 @@ Find your TUNIC game installation directory: BepInEx is a general purpose framework for modding Unity games, and is used to run the TUNIC Randomizer. -Download [BepInEx](https://github.com/BepInEx/BepInEx/releases/download/v6.0.0-pre.1/BepInEx_UnityIL2CPP_x64_6.0.0-pre.1.zip). +Download [BepInEx 6.0.0-pre.1 (Unity IL2CPP x64)](https://github.com/BepInEx/BepInEx/releases/tag/v6.0.0-pre.1). If playing on Steam Deck, follow this [guide to set up BepInEx via Proton](https://docs.bepinex.dev/articles/advanced/proton_wine.html). From 8c614865bb5094b6f2b520a12adaf7be61ae7a30 Mon Sep 17 00:00:00 2001 From: Trevor L <80716066+TRPG0@users.noreply.github.com> Date: Fri, 7 Jun 2024 11:11:35 -0600 Subject: [PATCH 26/36] Bomb Rush Cyberfunk: Fix missing location (#3475) --- worlds/bomb_rush_cyberfunk/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/worlds/bomb_rush_cyberfunk/__init__.py b/worlds/bomb_rush_cyberfunk/__init__.py index 2d078ae3bda9..98926e335138 100644 --- a/worlds/bomb_rush_cyberfunk/__init__.py +++ b/worlds/bomb_rush_cyberfunk/__init__.py @@ -109,7 +109,7 @@ def generate_early(self): def create_items(self): rep_locations: int = 87 if self.options.skip_polo_photos: - rep_locations -= 18 + rep_locations -= 17 self.options.total_rep.round_to_nearest_step() rep_counts = self.options.total_rep.get_rep_item_counts(self.random, rep_locations) @@ -157,7 +157,7 @@ def create_regions(self): self.get_region(n).add_exits(region_exits[n]) for index, loc in enumerate(location_table): - if self.options.skip_polo_photos and "Polo" in loc["name"]: + if self.options.skip_polo_photos and "Polo" in loc["game_id"]: continue stage: Region = self.get_region(loc["stage"]) stage.add_locations({loc["name"]: base_id + index}) From b053fee3e5d552fe2fbce8d4c879e3f9737dbc52 Mon Sep 17 00:00:00 2001 From: qwint Date: Fri, 7 Jun 2024 12:12:10 -0500 Subject: [PATCH 27/36] HK: adds schema to validate plando charm costs (#3471) --- worlds/hk/Options.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/worlds/hk/Options.py b/worlds/hk/Options.py index 0ad1acff5df3..38be2cd794a1 100644 --- a/worlds/hk/Options.py +++ b/worlds/hk/Options.py @@ -2,6 +2,7 @@ import re from .ExtractedData import logic_options, starts, pool_options from .Rules import cost_terms +from schema import And, Schema, Optional from Options import Option, DefaultOnToggle, Toggle, Choice, Range, OptionDict, NamedRange, DeathLink from .Charms import vanilla_costs, names as charm_names @@ -296,6 +297,9 @@ class PlandoCharmCosts(OptionDict): This is set after any random Charm Notch costs, if applicable.""" display_name = "Charm Notch Cost Plando" valid_keys = frozenset(charm_names) + schema = Schema({ + Optional(name): And(int, lambda n: 6 >= n >= 0) for name in charm_names + }) def get_costs(self, charm_costs: typing.List[int]) -> typing.List[int]: for name, cost in self.value.items(): From b3a2473853645931021c8d98a53419bd50424f1b Mon Sep 17 00:00:00 2001 From: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> Date: Fri, 7 Jun 2024 23:47:02 -0400 Subject: [PATCH 28/36] Docs: Fixing subject-verb agreement (#3491) --- WebHostLib/templates/weightedOptions/macros.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WebHostLib/templates/weightedOptions/macros.html b/WebHostLib/templates/weightedOptions/macros.html index a6e4545fdaf7..2682f9e8bc9c 100644 --- a/WebHostLib/templates/weightedOptions/macros.html +++ b/WebHostLib/templates/weightedOptions/macros.html @@ -34,7 +34,7 @@ Normal range: {{ option.range_start }} - {{ option.range_end }} {% if option.special_range_names %}

- The following values has special meaning, and may fall outside the normal range. + The following values have special meanings, and may fall outside the normal range.
    {% for name, value in option.special_range_names.items() %}
  • {{ value }}: {{ name }}
  • From 39deef5d09cc8c9bf1060e47c2843fa1d998bc44 Mon Sep 17 00:00:00 2001 From: Chris Wilson Date: Sat, 8 Jun 2024 04:54:14 -0400 Subject: [PATCH 29/36] Fix Choice and TextChoice options crashing WebHost if the option's default value is "random" (#3458) --- WebHostLib/templates/weightedOptions/macros.html | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/WebHostLib/templates/weightedOptions/macros.html b/WebHostLib/templates/weightedOptions/macros.html index 2682f9e8bc9c..55a56e32851d 100644 --- a/WebHostLib/templates/weightedOptions/macros.html +++ b/WebHostLib/templates/weightedOptions/macros.html @@ -18,7 +18,11 @@ {% for id, name in option.name_lookup.items() %} {% if name != 'random' %} - {{ RangeRow(option_name, option, option.get_option_name(id), name, False, name if option.get_option_name(option.default)|lower == name|lower else None) }} + {% if option.default != 'random' %} + {{ RangeRow(option_name, option, option.get_option_name(id), name, False, name if option.get_option_name(option.default)|lower == name else None) }} + {% else %} + {{ RangeRow(option_name, option, option.get_option_name(id), name) }} + {% endif %} {% endif %} {% endfor %} {{ RandomRow(option_name, option) }} @@ -92,7 +96,11 @@ {% for id, name in option.name_lookup.items() %} {% if name != 'random' %} - {{ RangeRow(option_name, option, option.get_option_name(id), name, False, name if option.get_option_name(option.default)|lower == name else None) }} + {% if option.default != 'random' %} + {{ RangeRow(option_name, option, option.get_option_name(id), name, False, name if option.get_option_name(option.default)|lower == name else None) }} + {% else %} + {{ RangeRow(option_name, option, option.get_option_name(id), name) }} + {% endif %} {% endif %} {% endfor %} {{ RandomRow(option_name, option) }} From 89d584e47442e9c3f71ded587715e21202993875 Mon Sep 17 00:00:00 2001 From: Fabian Dill Date: Sat, 8 Jun 2024 11:07:14 +0200 Subject: [PATCH 30/36] WebHost: allow getting checksum-specific datapackage via /api/datapackage/ (#3451) * WebHost: allow getting checksum-specific datapackage via /api/datapackage/ * match import style of /api/generate --- WebHostLib/api/__init__.py | 20 +------------------- WebHostLib/api/datapackage.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 19 deletions(-) create mode 100644 WebHostLib/api/datapackage.py diff --git a/WebHostLib/api/__init__.py b/WebHostLib/api/__init__.py index 22d1f19f6bdf..4003243a281d 100644 --- a/WebHostLib/api/__init__.py +++ b/WebHostLib/api/__init__.py @@ -5,7 +5,6 @@ from flask import Blueprint, abort, url_for import worlds.Files -from .. import cache from ..models import Room, Seed api_endpoints = Blueprint('api', __name__, url_prefix="/api") @@ -49,21 +48,4 @@ def supports_apdeltapatch(game: str): } -@api_endpoints.route('/datapackage') -@cache.cached() -def get_datapackage(): - from worlds import network_data_package - return network_data_package - - -@api_endpoints.route('/datapackage_checksum') -@cache.cached() -def get_datapackage_checksums(): - from worlds import network_data_package - version_package = { - game: game_data["checksum"] for game, game_data in network_data_package["games"].items() - } - return version_package - - -from . import generate, user # trigger registration +from . import generate, user, datapackage # trigger registration diff --git a/WebHostLib/api/datapackage.py b/WebHostLib/api/datapackage.py new file mode 100644 index 000000000000..3fb472d95dfd --- /dev/null +++ b/WebHostLib/api/datapackage.py @@ -0,0 +1,32 @@ +from flask import abort + +from Utils import restricted_loads +from WebHostLib import cache +from WebHostLib.models import GameDataPackage +from . import api_endpoints + + +@api_endpoints.route('/datapackage') +@cache.cached() +def get_datapackage(): + from worlds import network_data_package + return network_data_package + + +@api_endpoints.route('/datapackage/') +@cache.memoize(timeout=3600) +def get_datapackage_by_checksum(checksum: str): + package = GameDataPackage.get(checksum=checksum) + if package: + return restricted_loads(package.data) + return abort(404) + + +@api_endpoints.route('/datapackage_checksum') +@cache.cached() +def get_datapackage_checksums(): + from worlds import network_data_package + version_package = { + game: game_data["checksum"] for game, game_data in network_data_package["games"].items() + } + return version_package From a0653cdfe0d2524856f6afc5620ed331dcb328ed Mon Sep 17 00:00:00 2001 From: qwint Date: Sat, 8 Jun 2024 10:31:27 -0500 Subject: [PATCH 31/36] HK: adds split movement items to skills item group (#3462) --- worlds/hk/Items.py | 1 + 1 file changed, 1 insertion(+) diff --git a/worlds/hk/Items.py b/worlds/hk/Items.py index 0d4ab3d55f1e..8515465826a5 100644 --- a/worlds/hk/Items.py +++ b/worlds/hk/Items.py @@ -64,3 +64,4 @@ class HKItemData(NamedTuple): }) item_name_groups['Horizontal'] = item_name_groups['Cloak'] | item_name_groups['CDash'] item_name_groups['Vertical'] = item_name_groups['Claw'] | {'Monarch_Wings'} +item_name_groups['Skills'] |= item_name_groups['Vertical'] | item_name_groups['Horizontal'] From 302017c69e80b20c9471b6c8e5882e49671b2370 Mon Sep 17 00:00:00 2001 From: black-sliver <59490463+black-sliver@users.noreply.github.com> Date: Sat, 8 Jun 2024 17:51:09 +0200 Subject: [PATCH 32/36] Test: hosting: handle writes during start_room (#3492) Note: maybe we'd also want to add such handling to WebHost itself, but this is out of scope for getting hosting test to work. --- test/hosting/webhost.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/test/hosting/webhost.py b/test/hosting/webhost.py index e1e31ae466c4..4db605e8c1ea 100644 --- a/test/hosting/webhost.py +++ b/test/hosting/webhost.py @@ -66,12 +66,19 @@ def create_room(app_client: "FlaskClient", seed: str, auto_start: bool = False) def start_room(app_client: "FlaskClient", room_id: str, timeout: float = 30) -> str: from time import sleep + import pony.orm + poll_interval = .2 print(f"Starting room {room_id}") no_timeout = timeout <= 0 while no_timeout or timeout > 0: - response = app_client.get(f"/room/{room_id}") + try: + response = app_client.get(f"/room/{room_id}") + except pony.orm.core.OptimisticCheckError: + # hoster wrote to room during our transaction + continue + assert response.status_code == 200, f"Starting room for {room_id} failed: status {response.status_code}" match = re.search(r"/connect ([\w:.\-]+)", response.text) if match: From 0d9fce29c69caf0eb8e4f4eab1f1961a550c6d0c Mon Sep 17 00:00:00 2001 From: Fabian Dill Date: Sat, 8 Jun 2024 19:58:58 +0200 Subject: [PATCH 33/36] Core: load frozen decompressed worlds (#3488) --- worlds/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/worlds/__init__.py b/worlds/__init__.py index 83ee96131aa2..a0859290f90d 100644 --- a/worlds/__init__.py +++ b/worlds/__init__.py @@ -107,8 +107,9 @@ def load(self) -> bool: if not entry.name.startswith(("_", ".")): file_name = entry.name if relative else os.path.join(folder, entry.name) if entry.is_dir(): - init_file_path = os.path.join(entry.path, '__init__.py') - if os.path.isfile(init_file_path): + if os.path.isfile(os.path.join(entry.path, '__init__.py')): + world_sources.append(WorldSource(file_name, relative=relative)) + elif os.path.isfile(os.path.join(entry.path, '__init__.pyc')): world_sources.append(WorldSource(file_name, relative=relative)) else: logging.warning(f"excluding {entry.name} from world sources because it has no __init__.py") From 76804d295b7c33efb4671938c042a1d1e3b770e6 Mon Sep 17 00:00:00 2001 From: Fabian Dill Date: Sat, 8 Jun 2024 20:04:17 +0200 Subject: [PATCH 34/36] Core: explicitly import importlib.util (#3224) --- worlds/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/worlds/__init__.py b/worlds/__init__.py index a0859290f90d..8d784a5ba438 100644 --- a/worlds/__init__.py +++ b/worlds/__init__.py @@ -1,4 +1,5 @@ import importlib +import importlib.util import logging import os import sys From c478e55d7a56f2485e903a38f1919df45c4a379e Mon Sep 17 00:00:00 2001 From: Fabian Dill Date: Sun, 9 Jun 2024 03:13:27 +0200 Subject: [PATCH 35/36] Generate: improve logging capture (#3484) --- Generate.py | 38 +++++++++++++++++++++++--------------- Utils.py | 1 + 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/Generate.py b/Generate.py index 67988bf8b30d..0cef081120e6 100644 --- a/Generate.py +++ b/Generate.py @@ -1,10 +1,12 @@ from __future__ import annotations import argparse +import copy import logging import os import random import string +import sys import urllib.parse import urllib.request from collections import Counter @@ -15,21 +17,16 @@ ModuleUpdate.update() -import copy import Utils import Options from BaseClasses import seeddigits, get_seed, PlandoOptions -from Main import main as ERmain -from settings import get_settings from Utils import parse_yamls, version_tuple, __version__, tuplize_version -from worlds.alttp.EntranceRandomizer import parse_arguments -from worlds.AutoWorld import AutoWorldRegister -from worlds import failed_world_loads def mystery_argparse(): - options = get_settings() - defaults = options.generator + from settings import get_settings + settings = get_settings() + defaults = settings.generator parser = argparse.ArgumentParser(description="CMD Generation Interface, defaults come from host.yaml.") parser.add_argument('--weights_file_path', default=defaults.weights_file_path, @@ -41,7 +38,7 @@ def mystery_argparse(): parser.add_argument('--seed', help='Define seed number to generate.', type=int) parser.add_argument('--multi', default=defaults.players, type=lambda value: max(int(value), 1)) parser.add_argument('--spoiler', type=int, default=defaults.spoiler) - parser.add_argument('--outputpath', default=options.general_options.output_path, + parser.add_argument('--outputpath', default=settings.general_options.output_path, help="Path to output folder. Absolute or relative to cwd.") # absolute or relative to cwd parser.add_argument('--race', action='store_true', default=defaults.race) parser.add_argument('--meta_file_path', default=defaults.meta_file_path) @@ -61,20 +58,21 @@ def mystery_argparse(): if not os.path.isabs(args.meta_file_path): args.meta_file_path = os.path.join(args.player_files_path, args.meta_file_path) args.plando: PlandoOptions = PlandoOptions.from_option_string(args.plando) - return args, options + return args def get_seed_name(random_source) -> str: return f"{random_source.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits) -def main(args=None, callback=ERmain): +def main(args=None): if not args: - args, options = mystery_argparse() - else: - options = get_settings() + args = mystery_argparse() seed = get_seed(args.seed) + # __name__ == "__main__" check so unittests that already imported worlds don't trip this. + if __name__ == "__main__" and "worlds" in sys.modules: + raise Exception("Worlds system should not be loaded before logging init.") Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level) random.seed(seed) seed_name = get_seed_name(random) @@ -143,6 +141,9 @@ def main(args=None, callback=ERmain): raise Exception(f"No weights found. " f"Provide a general weights file ({args.weights_file_path}) or individual player files. " f"A mix is also permitted.") + + from worlds.AutoWorld import AutoWorldRegister + from worlds.alttp.EntranceRandomizer import parse_arguments erargs = parse_arguments(['--multi', str(args.multi)]) erargs.seed = seed erargs.plando_options = args.plando @@ -234,7 +235,8 @@ def main(args=None, callback=ERmain): with open(os.path.join(args.outputpath if args.outputpath else ".", f"generate_{seed_name}.yaml"), "wt") as f: yaml.dump(important, f) - return callback(erargs, seed) + from Main import main as ERmain + return ERmain(erargs, seed) def read_weights_yamls(path) -> Tuple[Any, ...]: @@ -359,6 +361,8 @@ def update_weights(weights: dict, new_weights: dict, update_type: str, name: str def roll_meta_option(option_key, game: str, category_dict: Dict) -> Any: + from worlds import AutoWorldRegister + if not game: return get_choice(option_key, category_dict) if game in AutoWorldRegister.world_types: @@ -436,10 +440,13 @@ def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, except Exception as e: raise Options.OptionError(f"Error generating option {option_key} in {ret.game}") from e else: + from worlds import AutoWorldRegister player_option.verify(AutoWorldRegister.world_types[ret.game], ret.name, plando_options) def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.bosses): + from worlds import AutoWorldRegister + if "linked_options" in weights: weights = roll_linked_options(weights) @@ -466,6 +473,7 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b ret.game = get_choice("game", weights) if ret.game not in AutoWorldRegister.world_types: + from worlds import failed_world_loads picks = Utils.get_fuzzy_results(ret.game, list(AutoWorldRegister.world_types) + failed_world_loads, limit=1)[0] if picks[0] in failed_world_loads: raise Exception(f"No functional world found to handle game {ret.game}. " diff --git a/Utils.py b/Utils.py index a7fd7f4f334c..f89330cf7c65 100644 --- a/Utils.py +++ b/Utils.py @@ -553,6 +553,7 @@ def _cleanup(): f"Archipelago ({__version__}) logging initialized" f" on {platform.platform()}" f" running Python {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" + f"{' (frozen)' if is_frozen() else ''}" ) From 2198a70251bae82114c9eb4691a04800ca4a610f Mon Sep 17 00:00:00 2001 From: Phaneros <31861583+MatthewMarinets@users.noreply.github.com> Date: Sat, 8 Jun 2024 19:08:47 -0700 Subject: [PATCH 36/36] Core: CommonClient: command history and echo (#3236) * client: Added command history access with up/down and command echo in common client * client: Changed command echo colour to orange * client: removed star import from typing * client: updated code style to match style guideline * client: adjusted ordering of calling parent constructor in command prompt input constructor * client: Fixed issues identified by beauxq in PR; fixed some typing issues * client: PR comments; replaced command history list with deque --- CommonClient.py | 5 ++++ NetUtils.py | 3 ++- data/client.kv | 1 + kvui.py | 62 +++++++++++++++++++++++++++++++++++++++++--- worlds/sc2/Client.py | 1 - 5 files changed, 67 insertions(+), 5 deletions(-) diff --git a/CommonClient.py b/CommonClient.py index 8af822cba571..8f1e64c0591b 100644 --- a/CommonClient.py +++ b/CommonClient.py @@ -493,6 +493,11 @@ def on_user_say(self, text: str) -> typing.Optional[str]: """Gets called before sending a Say to the server from the user. Returned text is sent, or sending is aborted if None is returned.""" return text + + def on_ui_command(self, text: str) -> None: + """Gets called by kivy when the user executes a command starting with `/` or `!`. + The command processor is still called; this is just intended for command echoing.""" + self.ui.print_json([{"text": text, "type": "color", "color": "orange"}]) def update_permissions(self, permissions: typing.Dict[str, int]): for permission_name, permission_flag in permissions.items(): diff --git a/NetUtils.py b/NetUtils.py index 076fdc3ba44f..f8d698c74fcc 100644 --- a/NetUtils.py +++ b/NetUtils.py @@ -198,7 +198,8 @@ class JSONtoTextParser(metaclass=HandlerMeta): "slateblue": "6D8BE8", "plum": "AF99EF", "salmon": "FA8072", - "white": "FFFFFF" + "white": "FFFFFF", + "orange": "FF7700", } def __init__(self, ctx): diff --git a/data/client.kv b/data/client.kv index bf98fa151770..dc8a5c9c9d72 100644 --- a/data/client.kv +++ b/data/client.kv @@ -13,6 +13,7 @@ plum: "AF99EF" # typically progression item salmon: "FA8072" # typically trap item white: "FFFFFF" # not used, if you want to change the generic text color change color in Label + orange: "FF7700" # Used for command echo