diff --git a/BaseClasses.py b/BaseClasses.py index b65178d2..a08b0cbe 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -5,12 +5,11 @@ from enum import Enum, unique import logging import json from collections import OrderedDict, Counter, deque -from typing import Union, Optional, List, Set, Dict, NamedTuple, Iterable +from typing import Union, Optional, List, Dict, NamedTuple, Iterable import secrets import random -from EntranceShuffle import door_addresses, indirect_connections -from Utils import int16_as_bytes +from EntranceShuffle import indirect_connections from Items import item_name_groups @@ -134,6 +133,7 @@ class World(object): set_player_attr('triforce_pieces_available', 30) set_player_attr('triforce_pieces_required', 20) set_player_attr('shop_shuffle', 'off') + set_player_attr('shop_shuffle_slots', 0) set_player_attr('shuffle_prizes', "g") set_player_attr('sprite_pool', []) set_player_attr('dark_room_logic', "lamp") @@ -415,7 +415,7 @@ class World(object): else: return all((self.has_beaten_game(state, p) for p in range(1, self.players + 1))) - def can_beat_game(self, starting_state=None): + def can_beat_game(self, starting_state : Optional[CollectionState]=None): if starting_state: if self.has_beaten_game(starting_state): return True @@ -447,6 +447,87 @@ class World(object): return False + def get_spheres(self): + state = CollectionState(self) + + locations = {location for location in self.get_locations()} + + while locations: + sphere = set() + + for location in locations: + if location.can_reach(state): + sphere.add(location) + yield sphere + if not sphere: + if locations: + yield locations # unreachable locations + break + + for location in sphere: + state.collect(location.item, True, location) + locations -= sphere + + + + def fulfills_accessibility(self, state: Optional[CollectionState] = None): + """Check if accessibility rules are fulfilled with current or supplied state.""" + if not state: + state = CollectionState(self) + players = {"none" : set(), + "items": set(), + "locations": set()} + for player, access in self.accessibility.items(): + players[access].add(player) + + beatable_fulfilled = False + + def location_conditition(location : Location): + """Determine if this location has to be accessible, location is already filtered by location_relevant""" + if location.player in players["none"]: + return False + return True + + def location_relevant(location : Location): + """Determine if this location is relevant to sweep.""" + if location.player in players["locations"] or location.event or \ + (location.item and location.item.advancement): + return True + return False + + def all_done(): + """Check if all access rules are fulfilled""" + if beatable_fulfilled: + if any(location_conditition(location) for location in locations): + return False # still locations required to be collected + return True + + locations = {location for location in self.get_locations() if location_relevant(location)} + + while locations: + sphere = set() + for location in locations: + if location.can_reach(state): + sphere.add(location) + + if not sphere: + # ran out of places and did not finish yet, quit + logging.debug(f"Could not access required locations.") + return False + + for location in sphere: + locations.remove(location) + state.collect(location.item, True, location) + + if self.has_beaten_game(state): + beatable_fulfilled = True + + if all_done(): + return True + + return False + + class CollectionState(object): def __init__(self, parent: World): @@ -980,7 +1061,7 @@ class Dungeon(object): def __str__(self): return self.world.get_name_string_for_object(self) if self.world else f'{self.name} (Player {self.player})' -class Boss(object): +class Boss(): def __init__(self, name, enemizer_name, defeat_rule, player: int): self.name = name self.enemizer_name = enemizer_name @@ -990,7 +1071,13 @@ class Boss(object): def can_defeat(self, state) -> bool: return self.defeat_rule(state, self.player) -class Location(object): + +class Location(): + shop_slot: bool = False + shop_slot_disabled: bool = False + event: bool = False + locked: bool = False + def __init__(self, player: int, name: str = '', address=None, crystal: bool = False, hint_text: Optional[str] = None, parent=None, player_address=None): @@ -1003,8 +1090,6 @@ class Location(object): self.spot_type = 'Location' self.hint_text: str = hint_text if hint_text else name self.recursion_count = 0 - self.event = False - self.locked = False self.always_allow = lambda item, state: False self.access_rule = lambda state: True self.item_rule = lambda item: True @@ -1029,6 +1114,9 @@ class Location(object): def __hash__(self): return hash((self.name, self.player)) + def __lt__(self, other): + return (self.player, self.name) < (other.player, other.name) + class Item(object): @@ -1086,105 +1174,6 @@ class Item(object): class Crystal(Item): pass -@unique -class ShopType(Enum): - Shop = 0 - TakeAny = 1 - UpgradeShop = 2 - -class Shop(): - slots = 3 # slot count is not dynamic in asm, however inventory can have None as empty slots - blacklist = set() # items that don't work, todo: actually check against this - type = ShopType.Shop - - def __init__(self, region: Region, room_id: int, shopkeeper_config: int, custom: bool, locked: bool): - self.region = region - self.room_id = room_id - self.inventory: List[Union[None, dict]] = [None] * self.slots - self.shopkeeper_config = shopkeeper_config - self.custom = custom - self.locked = locked - - @property - def item_count(self) -> int: - for x in range(self.slots - 1, -1, -1): # last x is 0 - if self.inventory[x]: - return x + 1 - return 0 - - def get_bytes(self) -> List[int]: - # [id][roomID-low][roomID-high][doorID][zero][shop_config][shopkeeper_config][sram_index] - entrances = self.region.entrances - config = self.item_count - if len(entrances) == 1 and entrances[0].name in door_addresses: - door_id = door_addresses[entrances[0].name][0] + 1 - else: - door_id = 0 - config |= 0x40 # ignore door id - if self.type == ShopType.TakeAny: - config |= 0x80 - elif self.type == ShopType.UpgradeShop: - config |= 0x10 # Alt. VRAM - return [0x00]+int16_as_bytes(self.room_id)+[door_id, 0x00, config, self.shopkeeper_config, 0x00] - - def has_unlimited(self, item: str) -> bool: - for inv in self.inventory: - if inv is None: - continue - if inv['item'] == item: - return True - if inv['max'] != 0 and inv['replacement'] is not None and inv['replacement'] == item: - return True - return False - - def has(self, item: str) -> bool: - for inv in self.inventory: - if inv is None: - continue - if inv['item'] == item: - return True - if inv['max'] != 0 and inv['replacement'] == item: - return True - return False - - def clear_inventory(self): - self.inventory = [None] * self.slots - - def add_inventory(self, slot: int, item: str, price: int, max: int = 0, - replacement: Optional[str] = None, replacement_price: int = 0, create_location: bool = False): - self.inventory[slot] = { - 'item': item, - 'price': price, - 'max': max, - 'replacement': replacement, - 'replacement_price': replacement_price, - 'create_location': create_location - } - - def push_inventory(self, slot: int, item: str, price: int, max: int = 1): - if not self.inventory[slot]: - raise ValueError("Inventory can't be pushed back if it doesn't exist") - - self.inventory[slot] = { - 'item': item, - 'price': price, - 'max': max, - 'replacement': self.inventory[slot]["item"], - 'replacement_price': self.inventory[slot]["price"], - 'create_location': self.inventory[slot]["create_location"] - } - - -class TakeAny(Shop): - type = ShopType.TakeAny - - -class UpgradeShop(Shop): - type = ShopType.UpgradeShop - # Potions break due to VRAM flags set in UpgradeShop. - # Didn't check for more things breaking as not much else can be shuffled here currently - blacklist = item_name_groups["Potions"] - class Spoiler(object): world: World @@ -1247,6 +1236,7 @@ class Spoiler(object): listed_locations.update(other_locations) self.shops = [] + from Shops import ShopType for shop in self.world.shops: if not shop.custom: continue @@ -1257,6 +1247,10 @@ class Spoiler(object): if item is None: continue shopdata['item_{}'.format(index)] = "{} — {}".format(item['item'], item['price']) if item['price'] else item['item'] + + if item['player'] > 0: + shopdata['item_{}'.format(index)] = shopdata['item_{}'.format(index)].replace('—', '(Player {}) — '.format(item['player'])) + if item['max'] == 0: continue shopdata['item_{}'.format(index)] += " x {}".format(item['max']) @@ -1330,6 +1324,7 @@ class Spoiler(object): 'triforce_pieces_available': self.world.triforce_pieces_available, 'triforce_pieces_required': self.world.triforce_pieces_required, 'shop_shuffle': self.world.shop_shuffle, + 'shop_shuffle_slots': self.world.shop_shuffle_slots, 'shuffle_prizes': self.world.shuffle_prizes, 'sprite_pool': self.world.sprite_pool, 'restrict_dungeon_item_on_boss': self.world.restrict_dungeon_item_on_boss diff --git a/Dungeons.py b/Dungeons.py index e47d06ea..b80d2152 100644 --- a/Dungeons.py +++ b/Dungeons.py @@ -144,7 +144,7 @@ def fill_dungeons_restrictive(world): # sort in the order Big Key, Small Key, Other before placing dungeon items sort_order = {"BigKey": 3, "SmallKey": 2} dungeon_items.sort(key=lambda item: sort_order.get(item.type, 1)) - fill_restrictive(world, all_state_base, locations, dungeon_items, True) + fill_restrictive(world, all_state_base, locations, dungeon_items, True, True) dungeon_music_addresses = {'Eastern Palace - Prize': [0x1559A], diff --git a/EntranceRandomizer.py b/EntranceRandomizer.py index 5b77e29a..7b3ac397 100755 --- a/EntranceRandomizer.py +++ b/EntranceRandomizer.py @@ -326,9 +326,17 @@ def parse_arguments(argv, no_defaults=False): parser.add_argument('--beemizer', default=defval(0), type=lambda value: min(max(int(value), 0), 4)) parser.add_argument('--shop_shuffle', default='', help='''\ combine letters for options: - i: shuffle the inventories of the shops around + g: generate default inventories for light and dark world shops, and unique shops + f: generate default inventories for each shop individually + i: shuffle the default inventories of the shops around p: randomize the prices of the items in shop inventories u: shuffle capacity upgrades into the item pool + w: consider witch's hut like any other shop and shuffle/randomize it too + ''') + parser.add_argument('--shop_shuffle_slots', default=defval(0), + type=lambda value: min(max(int(value), 1), 96), + help=''' + Maximum amount of shop slots able to be filled by items from the item pool. ''') parser.add_argument('--shuffle_prizes', default=defval('g'), choices=['', 'g', 'b', 'gb']) parser.add_argument('--sprite_pool', help='''\ @@ -390,7 +398,8 @@ def parse_arguments(argv, no_defaults=False): 'shufflebosses', 'enemy_shuffle', 'enemy_health', 'enemy_damage', 'shufflepots', 'ow_palettes', 'uw_palettes', 'sprite', 'disablemusic', 'quickswap', 'fastmenu', 'heartcolor', 'heartbeep', "skip_progression_balancing", "triforce_pieces_available", - "triforce_pieces_required", "shop_shuffle", "required_medallions", + "triforce_pieces_required", "shop_shuffle", "shop_shuffle_slots", + "required_medallions", "plando_items", "plando_texts", "plando_connections", 'remote_items', 'progressive', 'dungeon_counters', 'glitch_boots', 'killable_thieves', 'tile_shuffle', 'bush_shuffle', 'shuffle_prizes', 'sprite_pool', 'dark_room_logic', diff --git a/Fill.py b/Fill.py index 0c694898..b887c3aa 100644 --- a/Fill.py +++ b/Fill.py @@ -1,7 +1,7 @@ import logging import typing -from BaseClasses import CollectionState, PlandoItem +from BaseClasses import CollectionState, PlandoItem, Location from Items import ItemFactory from Regions import key_drop_data @@ -10,7 +10,8 @@ class FillError(RuntimeError): pass -def fill_restrictive(world, base_state: CollectionState, locations, itempool, single_player_placement=False): +def fill_restrictive(world, base_state: CollectionState, locations, itempool, single_player_placement=False, + lock=False): def sweep_from_pool(): new_state = base_state.copy() for item in itempool: @@ -59,6 +60,8 @@ def fill_restrictive(world, base_state: CollectionState, locations, itempool, si f'Already placed {len(placements)}: {", ".join(str(place) for place in placements)}') world.push_item(spot_to_fill, item_to_place, False) + if lock: + spot_to_fill.locked = True locations.remove(spot_to_fill) placements.append(spot_to_fill) spot_to_fill.event = True @@ -168,6 +171,7 @@ def distribute_items_restrictive(world, gftower_trash=False, fill_locations=None fill_locations.remove(spot_to_fill) world.random.shuffle(fill_locations) + prioitempool, fill_locations = fast_fill(world, prioitempool, fill_locations) restitempool, fill_locations = fast_fill(world, restitempool, fill_locations) @@ -244,6 +248,7 @@ def flood_items(world): itempool.remove(item_to_place) break + def balance_multiworld_progression(world): balanceable_players = {player for player in range(1, world.players + 1) if world.progression_balancing[player]} if not balanceable_players: @@ -331,7 +336,8 @@ def balance_multiworld_progression(world): replacement_locations.insert(0, new_location) new_location = replacement_locations.pop() - new_location.item, old_location.item = old_location.item, new_location.item + swap_location_item(old_location, new_location) + new_location.event, old_location.event = True, False logging.debug(f"Progression balancing moved {new_location.item} to {new_location}, " f"displacing {old_location.item} in {old_location}") @@ -355,6 +361,18 @@ def balance_multiworld_progression(world): raise RuntimeError('Not all required items reachable. Something went terribly wrong here.') +def swap_location_item(location_1: Location, location_2: Location, check_locked=True): + """Swaps Items of locations. Does NOT swap flags like event, shop_slot or locked""" + if check_locked: + if location_1.locked: + logging.warning(f"Swapping {location_1}, which is marked as locked.") + if location_2.locked: + logging.warning(f"Swapping {location_2}, which is marked as locked.") + location_2.item, location_1.item = location_1.item, location_2.item + location_1.item.location = location_1 + location_2.item.location = location_2 + + def distribute_planned(world): world_name_lookup = {world.player_names[player_id][0]: player_id for player_id in world.player_ids} @@ -362,7 +380,8 @@ def distribute_planned(world): placement: PlandoItem for placement in world.plando_items[player]: if placement.location in key_drop_data: - placement.warn(f"Can't place '{placement.item}' at '{placement.location}', as key drop shuffle locations are not supported yet.") + placement.warn( + f"Can't place '{placement.item}' at '{placement.location}', as key drop shuffle locations are not supported yet.") continue item = ItemFactory(placement.item, player) target_world: int = placement.world diff --git a/ItemPool.py b/ItemPool.py index 3bb9f13e..44d0df31 100644 --- a/ItemPool.py +++ b/ItemPool.py @@ -1,7 +1,8 @@ from collections import namedtuple import logging -from BaseClasses import Region, RegionType, ShopType, Location, TakeAny +from BaseClasses import Region, RegionType, Location +from Shops import ShopType, Shop, TakeAny, total_shop_slots from Bosses import place_bosses from Dungeons import get_dungeon_item_pool from EntranceShuffle import connect_entrance @@ -460,10 +461,12 @@ def shuffle_shops(world, items, player: int): world.random.shuffle(new_items) # Decide what gets tossed randomly if it can't insert everything. + capacityshop: Shop = None for shop in world.shops: if shop.type == ShopType.UpgradeShop and shop.region.player == player and \ shop.region.name == "Capacity Upgrade": shop.clear_inventory() + capacityshop = shop if world.goal[player] != 'icerodhunt': for i, item in enumerate(items): @@ -472,7 +475,13 @@ def shuffle_shops(world, items, player: int): if not new_items: break else: - logging.warning(f"Not all upgrades put into Player{player}' item pool. Still missing: {new_items}") + logging.warning(f"Not all upgrades put into Player{player}' item pool. Putting remaining items in Capacity Upgrade shop instead.") + bombupgrades = sum(1 for item in new_items if 'Bomb Upgrade' in item) + arrowupgrades = sum(1 for item in new_items if 'Arrow Upgrade' in item) + if bombupgrades: + capacityshop.add_inventory(1, 'Bomb Upgrade (+5)', 100, bombupgrades) + if arrowupgrades: + capacityshop.add_inventory(1, 'Arrow Upgrade (+5)', 100, arrowupgrades) else: for item in new_items: world.push_precollected(ItemFactory(item, player)) @@ -485,14 +494,19 @@ def shuffle_shops(world, items, player: int): if shop.region.player == player: if shop.type == ShopType.UpgradeShop: upgrade_shops.append(shop) - elif shop.type == ShopType.Shop and shop.region.name != 'Potion Shop': - shops.append(shop) - total_inventory.extend(shop.inventory) + elif shop.type == ShopType.Shop: + if shop.region.name == 'Potion Shop' and not 'w' in option: + # don't modify potion shop + pass + else: + shops.append(shop) + total_inventory.extend(shop.inventory) if 'p' in option: def price_adjust(price: int) -> int: # it is important that a base price of 0 always returns 0 as new price! - return int(price * (0.5 + world.random.random() * 1.5)) + adjust = 2 if price < 100 else 5 + return int((price / adjust) * (0.5 + world.random.random() * 1.5)) * adjust def adjust_item(item): if item: @@ -507,6 +521,7 @@ def shuffle_shops(world, items, player: int): if 'i' in option: world.random.shuffle(total_inventory) + i = 0 for shop in shops: slots = shop.slots @@ -548,7 +563,7 @@ def set_up_take_anys(world, player): entrance = world.get_region(reg, player).entrances[0] connect_entrance(world, entrance.name, old_man_take_any.name, player) entrance.target = 0x58 - old_man_take_any.shop = TakeAny(old_man_take_any, 0x0112, 0xE2, True, True) + old_man_take_any.shop = TakeAny(old_man_take_any, 0x0112, 0xE2, True, True, total_shop_slots) world.shops.append(old_man_take_any.shop) swords = [item for item in world.itempool if item.type == 'Sword' and item.player == player] @@ -570,7 +585,7 @@ def set_up_take_anys(world, player): entrance = world.get_region(reg, player).entrances[0] connect_entrance(world, entrance.name, take_any.name, player) entrance.target = target - take_any.shop = TakeAny(take_any, room_id, 0xE3, True, True) + take_any.shop = TakeAny(take_any, room_id, 0xE3, True, True, total_shop_slots + num + 1) world.shops.append(take_any.shop) take_any.shop.add_inventory(0, 'Blue Potion', 0, 0) take_any.shop.add_inventory(1, 'Boss Heart Container', 0, 0) @@ -584,13 +599,14 @@ def create_dynamic_shop_locations(world, player): if item is None: continue if item['create_location']: - loc = Location(player, "{} Item {}".format(shop.region.name, i+1), parent=shop.region) + loc = Location(player, "{} Slot {}".format(shop.region.name, i + 1), parent=shop.region) shop.region.locations.append(loc) world.dynamic_locations.append(loc) world.clear_location_cache() world.push_item(loc, ItemFactory(item['item'], player), False) + loc.shop_slot = True loc.event = True loc.locked = True @@ -611,7 +627,7 @@ def fill_prizes(world, attempts=15): prize_locs = list(empty_crystal_locations) world.random.shuffle(prizepool) world.random.shuffle(prize_locs) - fill_restrictive(world, all_state, prize_locs, prizepool, True) + fill_restrictive(world, all_state, prize_locs, prizepool, True, lock=True) except FillError as e: logging.getLogger('').exception("Failed to place dungeon prizes (%s). Will retry %s more times", e, attempts - attempt) diff --git a/Items.py b/Items.py index c896e483..cac7db1e 100644 --- a/Items.py +++ b/Items.py @@ -169,6 +169,12 @@ item_table = {'Bow': (True, False, None, 0x0B, 'You have\nchosen the\narcher cla 'Small Key (Universal)': (False, True, None, 0xAF, 'A small key for any door', 'and the key', 'the unlocking kid', 'keys for sale', 'unlock the fungus', 'key boy opens door again', 'a small key'), 'Nothing': (False, False, None, 0x5A, 'Some Hot Air', 'and the Nothing', 'the zen kid', 'outright theft', 'shroom theft', 'empty boy is bored again', 'nothing'), 'Bee Trap': (False, False, None, 0xB0, 'We will sting your face a whole lot!', 'and the sting buddies', 'the beekeeper kid', 'insects for sale', 'shroom pollenation', 'bottle boy has mad bees again', 'Friendship'), + 'Faerie': (False, False, None, 0xB1, 'Save me and I will revive you', 'and the captive', 'the tingle kid','hostage for sale', 'fairy dust and shrooms', 'bottle boy has friend again', 'a faerie'), + 'Good Bee': (False, False, None, 0xB2, 'Save me and I will sting you (sometimes)', 'and the captive', 'the tingle kid','hostage for sale', 'good dust and shrooms', 'bottle boy has friend again', 'a bee'), + 'Magic Jar': (False, False, None, 0xB3, '', '', '','', '', '', ''), + 'Apple': (False, False, None, 0xB4, '', '', '','', '', '', ''), + # 'Hint': (False, False, None, 0xB5, '', '', '','', '', '', ''), + # 'Bomb Trap': (False, False, None, 0xB6, '', '', '','', '', '', ''), 'Red Potion': (False, False, None, 0x2E, 'Hearty red goop!', 'and the red goo', 'the liquid kid', 'potion for sale', 'free samples', 'bottle boy has red goo again', 'a red potion'), 'Green Potion': (False, False, None, 0x2F, 'Refreshing green goop!', 'and the green goo', 'the liquid kid', 'potion for sale', 'free samples', 'bottle boy has green goo again', 'a green potion'), 'Blue Potion': (False, False, None, 0x30, 'Delicious blue goop!', 'and the blue goo', 'the liquid kid', 'potion for sale', 'free samples', 'bottle boy has blue goo again', 'a blue potion'), diff --git a/Main.py b/Main.py index 29f70017..281e3f03 100644 --- a/Main.py +++ b/Main.py @@ -9,9 +9,10 @@ import time import zlib import concurrent.futures -from BaseClasses import World, CollectionState, Item, Region, Location, PlandoItem +from BaseClasses import World, CollectionState, Item, Region, Location +from Shops import ShopSlotFill, create_shops, SHOP_ID_START, FillDisabledShopSlots from Items import ItemFactory, item_table, item_name_groups -from Regions import create_regions, create_shops, mark_light_world_regions, lookup_vanilla_location_to_entrance +from Regions import create_regions, mark_light_world_regions, lookup_vanilla_location_to_entrance from InvertedRegions import create_inverted_regions, mark_dark_world_regions from EntranceShuffle import link_entrances, link_inverted_entrances, plando_connect from Rom import patch_rom, patch_race_rom, patch_enemizer, apply_rom_settings, LocalRom, get_hash_string @@ -83,6 +84,7 @@ def main(args, seed=None): world.triforce_pieces_available = args.triforce_pieces_available.copy() world.triforce_pieces_required = args.triforce_pieces_required.copy() world.shop_shuffle = args.shop_shuffle.copy() + world.shop_shuffle_slots = args.shop_shuffle_slots.copy() world.progression_balancing = {player: not balance for player, balance in args.skip_progression_balancing.items()} world.shuffle_prizes = args.shuffle_prizes.copy() world.sprite_pool = args.sprite_pool.copy() @@ -209,8 +211,14 @@ def main(args, seed=None): if world.players > 1: balance_multiworld_progression(world) + logger.info("Filling Shop Slots") + + ShopSlotFill(world) + logger.info('Patching ROM.') + + outfilebase = 'BM_%s' % (args.outputname if args.outputname else world.seed) rom_names = [] @@ -245,7 +253,6 @@ def main(args, seed=None): args.fastmenu[player], args.disablemusic[player], args.sprite[player], palettes_options, world, player, True) - mcsb_name = '' if all([world.mapshuffle[player], world.compassshuffle[player], world.keyshuffle[player], world.bigkeyshuffle[player]]): @@ -281,7 +288,7 @@ def main(args, seed=None): "progressive": world.progressive, # A "hints": 'True' if world.hints[player] else 'False' # B } - # 0 1 2 3 4 5 6 7 8 9 A B + # 0 1 2 3 4 5 6 7 8 9 A B outfilesuffix = ('_%s_%s-%s-%s-%s%s_%s-%s%s%s%s%s' % ( # 0 1 2 3 4 5 6 7 8 9 A B C # _noglitches_normal-normal-open-ganon-ohko_simple-balanced-keysanity-retro-prog_random-nohints @@ -312,7 +319,7 @@ def main(args, seed=None): pool = concurrent.futures.ThreadPoolExecutor() multidata_task = None - check_beatability_task = pool.submit(world.can_beat_game) + check_accessibility_task = pool.submit(world.fulfills_accessibility) if not args.suppress_rom: rom_futures = [] @@ -329,13 +336,14 @@ def main(args, seed=None): return get_entrance_to_region(entrance.parent_region) # collect ER hint info - er_hint_data = {player: {} for player in range(1, world.players + 1) if world.shuffle[player] != "vanilla"} + er_hint_data = {player: {} for player in range(1, world.players + 1) if world.shuffle[player] != "vanilla" or world.retro[player]} from Regions import RegionType for region in world.regions: if region.player in er_hint_data and region.locations: main_entrance = get_entrance_to_region(region) for location in region.locations: if type(location.address) == int: # skips events and crystals + if location.address >= SHOP_ID_START + 33: continue if lookup_vanilla_location_to_entrance[location.address] != main_entrance.name: er_hint_data[region.player][location.address] = main_entrance.name @@ -362,11 +370,27 @@ def main(args, seed=None): checks_in_area[location.player]["Dark World"].append(location.address) checks_in_area[location.player]["Total"] += 1 + oldmancaves = [] + for region in [world.get_region("Old Man Sword Cave", player) for player in range(1, world.players + 1) if world.retro[player]]: + item = ItemFactory(region.shop.inventory[0]['item'], region.player) + player = region.player + location_id = SHOP_ID_START + 33 + + if region.type == RegionType.LightWorld: + checks_in_area[player]["Light World"].append(location_id) + else: + checks_in_area[player]["Dark World"].append(location_id) + checks_in_area[player]["Total"] += 1 + + er_hint_data[player][location_id] = get_entrance_to_region(region).name + oldmancaves.append(((location_id, player), (item.code, player))) precollected_items = [[] for player in range(world.players)] for item in world.precollected_items: precollected_items[item.player - 1].append(item.code) + FillDisabledShopSlots(world) + def write_multidata(roms): for future in roms: rom_name = future.result() @@ -378,7 +402,11 @@ def main(args, seed=None): multidatatags.append("Spoiler") if not args.skip_playthrough: multidatatags.append("Play through") - minimum_versions = {"server": (1,0,0)} + minimum_versions = {"server": (1, 0, 0)} + minimum_versions["clients"] = client_versions = [] + for (slot, team, name) in rom_names: + if world.shop_shuffle_slots[slot]: + client_versions.append([team, slot, [3, 6, 1]]) multidata = zlib.compress(json.dumps({"names": parsed_names, # backwards compat for < 2.4.1 "roms": [(slot, team, list(name.encode())) @@ -389,7 +417,7 @@ def main(args, seed=None): "locations": [((location.address, location.player), (location.item.code, location.item.player)) for location in world.get_filled_locations() if - type(location.address) is int], + type(location.address) is int] + oldmancaves, "checks_in_area": checks_in_area, "server_options": get_options()["server_options"], "er_hint_data": er_hint_data, @@ -403,8 +431,11 @@ def main(args, seed=None): f.write(multidata) multidata_task = pool.submit(write_multidata, rom_futures) - if not check_beatability_task.result(): - raise Exception("Game appears unbeatable. Aborting.") + if not check_accessibility_task.result(): + if not world.can_beat_game(): + raise Exception("Game appears is unbeatable. Aborting.") + else: + logger.warning("Location Accessibility requirements not fulfilled.") if not args.skip_playthrough: logger.info('Calculating playthrough.') create_playthrough(world) @@ -458,6 +489,7 @@ def copy_world(world): ret.shufflepots = world.shufflepots.copy() ret.shuffle_prizes = world.shuffle_prizes.copy() ret.shop_shuffle = world.shop_shuffle.copy() + ret.shop_shuffle_slots = world.shop_shuffle_slots.copy() ret.dark_room_logic = world.dark_room_logic.copy() ret.restrict_dungeon_item_on_boss = world.restrict_dungeon_item_on_boss.copy() @@ -530,7 +562,7 @@ def copy_dynamic_regions_and_locations(world, ret): if region.shop: new_reg.shop = region.shop.__class__(new_reg, region.shop.room_id, region.shop.shopkeeper_config, - region.shop.custom, region.shop.locked) + region.shop.custom, region.shop.locked, region.shop.sram_offset) ret.shops.append(new_reg.shop) for location in world.dynamic_locations: diff --git a/MultiClient.py b/MultiClient.py index 3f9cef0f..7ef16b02 100644 --- a/MultiClient.py +++ b/MultiClient.py @@ -18,6 +18,7 @@ import shutil from random import randrange +import Shops from Utils import get_item_name_from_id, get_location_name_from_address, ReceivedItem exit_func = atexit.register(input, "Press enter to close.") @@ -93,7 +94,6 @@ class Context(): self.player_names: typing.Dict[int: str] = {} self.locations_recognized = set() self.locations_checked = set() - self.unsafe_locations_checked = set() self.locations_scouted = set() self.items_received = [] self.items_missing = [] @@ -104,7 +104,6 @@ class Context(): self.prev_rom = None self.auth = None self.found_items = found_items - self.send_unsafe = False self.finished_game = False self.slow_mode = False @@ -168,6 +167,11 @@ SCOUT_LOCATION_ADDR = SAVEDATA_START + 0x4D7 # 1 byte SCOUTREPLY_LOCATION_ADDR = SAVEDATA_START + 0x4D8 # 1 byte SCOUTREPLY_ITEM_ADDR = SAVEDATA_START + 0x4D9 # 1 byte SCOUTREPLY_PLAYER_ADDR = SAVEDATA_START + 0x4DA # 1 byte +SHOP_ADDR = SAVEDATA_START + 0x302 # 2 bytes + + +location_shop_order = [name for name, info in Shops.shop_table.items()] # probably don't leave this here. This relies on python 3.6+ dictionary keys having defined order +location_shop_ids = set([info[0] for name, info in Shops.shop_table.items()]) location_table_uw = {"Blind's Hideout - Top": (0x11d, 0x10), "Blind's Hideout - Left": (0x11d, 0x20), @@ -878,9 +882,6 @@ async def process_server_cmd(ctx: Context, cmd, args): raise Exception('Connection refused by the multiworld host, no reason provided') elif cmd == 'Connected': - if ctx.send_unsafe: - ctx.send_unsafe = False - logger.info(f'Turning off sending of ALL location checks not declared as missing. If you want it on, please use /send_unsafe true') Utils.persistent_store("servers", "default", ctx.server_address) Utils.persistent_store("servers", ctx.rom, ctx.server_address) ctx.team, ctx.slot = args[0] @@ -1131,15 +1132,6 @@ class ClientCommandProcessor(CommandProcessor): else: self.output("Web UI was never started.") - def _cmd_send_unsafe(self, toggle: str = ""): - """Force sending of locations the server did not specify was actually missing. WARNING: This may brick online trackers. Turned off on reconnect.""" - if toggle: - self.ctx.send_unsafe = toggle.lower() in {"1", "true", "on"} - logger.info(f'Turning {("on" if self.ctx.send_unsafe else "off")} the option to send ALL location checks to the multiserver.') - else: - logger.info("You must specify /send_unsafe true explicitly.") - self.ctx.send_unsafe = False - def default(self, raw: str): asyncio.create_task(self.ctx.send_msgs([['Say', raw]])) @@ -1169,11 +1161,11 @@ async def track_locations(ctx : Context, roomid, roomdata): new_locations = [] def new_check(location): - ctx.unsafe_locations_checked.add(location) + ctx.locations_checked.add(location) check = None if ctx.items_checked is None: - check = f'New Check: {location} ({len(ctx.unsafe_locations_checked)}/{len(Regions.lookup_name_to_id)})' + check = f'New Check: {location} ({len(ctx.locations_checked)}/{len(Regions.lookup_name_to_id)})' else: items_total = len(ctx.items_missing) + len(ctx.items_checked) if location in ctx.items_missing or location in ctx.items_checked: @@ -1184,9 +1176,21 @@ async def track_locations(ctx : Context, roomid, roomdata): logger.info(check) ctx.ui_node.send_location_check(ctx, location) + try: + if roomid in location_shop_ids: + misc_data = await snes_read(ctx, SHOP_ADDR, (len(location_shop_order)*3)+5) + for cnt, b in enumerate(misc_data): + my_check = Shops.shop_table_by_location_id[Shops.SHOP_ID_START + cnt] + if int(b) > 0 and my_check not in ctx.locations_checked: + new_check(my_check) + except Exception as e: + print(e) + logger.info(f"Exception: {e}") + + for location, (loc_roomid, loc_mask) in location_table_uw.items(): try: - if location not in ctx.unsafe_locations_checked and loc_roomid == roomid and (roomdata << 4) & loc_mask != 0: + if location not in ctx.locations_checked and loc_roomid == roomid and (roomdata << 4) & loc_mask != 0: new_check(location) except Exception as e: logger.exception(f"Exception: {e}") @@ -1195,7 +1199,7 @@ async def track_locations(ctx : Context, roomid, roomdata): uw_end = 0 uw_unchecked = {} for location, (roomid, mask) in location_table_uw.items(): - if location not in ctx.unsafe_locations_checked: + if location not in ctx.locations_checked: uw_unchecked[location] = (roomid, mask) uw_begin = min(uw_begin, roomid) uw_end = max(uw_end, roomid + 1) @@ -1212,7 +1216,7 @@ async def track_locations(ctx : Context, roomid, roomdata): ow_end = 0 ow_unchecked = {} for location, screenid in location_table_ow.items(): - if location not in ctx.unsafe_locations_checked: + if location not in ctx.locations_checked: ow_unchecked[location] = screenid ow_begin = min(ow_begin, screenid) ow_end = max(ow_end, screenid + 1) @@ -1223,26 +1227,30 @@ async def track_locations(ctx : Context, roomid, roomdata): if ow_data[screenid - ow_begin] & 0x40 != 0: new_check(location) - if not all([location in ctx.unsafe_locations_checked for location in location_table_npc.keys()]): + if not all([location in ctx.locations_checked for location in location_table_npc.keys()]): npc_data = await snes_read(ctx, SAVEDATA_START + 0x410, 2) if npc_data is not None: npc_value = npc_data[0] | (npc_data[1] << 8) for location, mask in location_table_npc.items(): - if npc_value & mask != 0 and location not in ctx.unsafe_locations_checked: + if npc_value & mask != 0 and location not in ctx.locations_checked: new_check(location) - if not all([location in ctx.unsafe_locations_checked for location in location_table_misc.keys()]): + if not all([location in ctx.locations_checked for location in location_table_misc.keys()]): misc_data = await snes_read(ctx, SAVEDATA_START + 0x3c6, 4) if misc_data is not None: for location, (offset, mask) in location_table_misc.items(): assert(0x3c6 <= offset <= 0x3c9) - if misc_data[offset - 0x3c6] & mask != 0 and location not in ctx.unsafe_locations_checked: + if misc_data[offset - 0x3c6] & mask != 0 and location not in ctx.locations_checked: new_check(location) - for location in ctx.unsafe_locations_checked: - if (location in ctx.items_missing and location not in ctx.locations_checked) or ctx.send_unsafe: - ctx.locations_checked.add(location) - new_locations.append(Regions.lookup_name_to_id[location]) + for location in ctx.locations_checked: + try: + my_id = Regions.lookup_name_to_id.get(location, Shops.shop_table_by_location.get(location, -1)) + new_locations.append(my_id) + except Exception as e: + print(e) + logger.info(f"Exception: {e}") + await ctx.send_msgs([['LocationChecks', new_locations]]) @@ -1274,7 +1282,6 @@ async def game_watcher(ctx : Context): ctx.rom = rom.decode() if not ctx.prev_rom or ctx.prev_rom != ctx.rom: ctx.locations_checked = set() - ctx.unsafe_locations_checked = set() ctx.locations_scouted = set() ctx.prev_rom = ctx.rom diff --git a/Mystery.py b/Mystery.py index 94511822..4293365a 100644 --- a/Mystery.py +++ b/Mystery.py @@ -405,6 +405,8 @@ def roll_settings(weights, plando_options: typing.Set[str] = frozenset(("bosses" # change minimum to required pieces to avoid problems ret.triforce_pieces_available = min(max(ret.triforce_pieces_required, int(ret.triforce_pieces_available)), 90) + ret.shop_shuffle_slots = int(get_choice('shop_shuffle_slots', weights, '0')) + ret.shop_shuffle = get_choice('shop_shuffle', weights, '') if not ret.shop_shuffle: ret.shop_shuffle = '' diff --git a/Regions.py b/Regions.py index 4326e7d1..fdd03fae 100644 --- a/Regions.py +++ b/Regions.py @@ -1,7 +1,8 @@ import collections import typing -from BaseClasses import Region, Location, Entrance, RegionType, Shop, TakeAny, UpgradeShop, ShopType +from BaseClasses import Region, Location, Entrance, RegionType + def create_regions(world, player): @@ -365,38 +366,6 @@ def mark_light_world_regions(world, player: int): queue.append(exit.connected_region) -def create_shops(world, player: int): - cls_mapping = {ShopType.UpgradeShop: UpgradeShop, - ShopType.Shop: Shop, - ShopType.TakeAny: TakeAny} - for region_name, (room_id, type, shopkeeper, custom, locked, inventory) in shop_table.items(): - if world.mode[player] == 'inverted' and region_name == 'Dark Lake Hylia Shop': - locked = True - inventory = [('Blue Potion', 160), ('Blue Shield', 50), ('Bombs (10)', 50)] - region = world.get_region(region_name, player) - shop = cls_mapping[type](region, room_id, shopkeeper, custom, locked) - region.shop = shop - world.shops.append(shop) - for index, item in enumerate(inventory): - shop.add_inventory(index, *item) - -# (type, room_id, shopkeeper, custom, locked, [items]) -# item = (item, price, max=0, replacement=None, replacement_price=0) -_basic_shop_defaults = [('Red Potion', 150), ('Small Heart', 10), ('Bombs (10)', 50)] -_dark_world_shop_defaults = [('Red Potion', 150), ('Blue Shield', 50), ('Bombs (10)', 50)] -shop_table = { - 'Cave Shop (Dark Death Mountain)': (0x0112, ShopType.Shop, 0xC1, True, False, _basic_shop_defaults), - 'Red Shield Shop': (0x0110, ShopType.Shop, 0xC1, True, False, [('Red Shield', 500), ('Bee', 10), ('Arrows (10)', 30)]), - 'Dark Lake Hylia Shop': (0x010F, ShopType.Shop, 0xC1, True, False, _dark_world_shop_defaults), - 'Dark World Lumberjack Shop': (0x010F, ShopType.Shop, 0xC1, True, False, _dark_world_shop_defaults), - 'Village of Outcasts Shop': (0x010F, ShopType.Shop, 0xC1, True, False, _dark_world_shop_defaults), - 'Dark World Potion Shop': (0x010F, ShopType.Shop, 0xC1, True, False, _dark_world_shop_defaults), - 'Light World Death Mountain Shop': (0x00FF, ShopType.Shop, 0xA0, True, False, _basic_shop_defaults), - 'Kakariko Shop': (0x011F, ShopType.Shop, 0xA0, True, False, _basic_shop_defaults), - 'Cave Shop (Lake Hylia)': (0x0112, ShopType.Shop, 0xA0, True, False, _basic_shop_defaults), - 'Potion Shop': (0x0109, ShopType.Shop, 0xFF, False, True, [('Red Potion', 120), ('Green Potion', 60), ('Blue Potion', 160)]), - 'Capacity Upgrade': (0x0115, ShopType.UpgradeShop, 0x04, True, True, [('Bomb Upgrade (+5)', 100, 7), ('Arrow Upgrade (+5)', 100, 7)]) -} old_location_address_to_new_location_address = { 0x2eb18: 0x18001b, # Bottle Merchant @@ -703,10 +672,13 @@ location_table: typing.Dict[str, 'Turtle Rock - Prize': ( [0x120A7, 0x53F24, 0x53F25, 0x18005C, 0x180079, 0xC708], None, True, 'Turtle Rock')} +from Shops import shop_table_by_location_id, shop_table_by_location lookup_id_to_name = {data[0]: name for name, data in location_table.items() if type(data[0]) == int} lookup_id_to_name = {**lookup_id_to_name, **{data[1]: name for name, data in key_drop_data.items()}, -1: "cheat console"} +lookup_id_to_name.update(shop_table_by_location_id) lookup_name_to_id = {name: data[0] for name, data in location_table.items() if type(data[0]) == int} lookup_name_to_id = {**lookup_name_to_id, **{name: data[1] for name, data in key_drop_data.items()}, "cheat console": -1} +lookup_name_to_id.update(shop_table_by_location) lookup_vanilla_location_to_entrance = {1572883: 'Kings Grave Inner Rocks', 191256: 'Kings Grave Inner Rocks', 1573194: 'Kings Grave Inner Rocks', 1573189: 'Kings Grave Inner Rocks', @@ -832,7 +804,18 @@ lookup_vanilla_location_to_entrance = {1572883: 'Kings Grave Inner Rocks', 19125 0x140064: 'Misery Mire', 0x140058: 'Turtle Rock', 0x140007: 'Dark Death Mountain Ledge (West)', 0x140040: 'Ganons Tower', 0x140043: 'Ganons Tower', - 0x14003a: 'Ganons Tower', 0x14001f: 'Ganons Tower'} + 0x14003a: 'Ganons Tower', 0x14001f: 'Ganons Tower', + 0x400000: 'Cave Shop (Dark Death Mountain)', 0x400001: 'Cave Shop (Dark Death Mountain)', 0x400002: 'Cave Shop (Dark Death Mountain)', + 0x400003: 'Red Shield Shop', 0x400004: 'Red Shield Shop', 0x400005: 'Red Shield Shop', + 0x400006: 'Dark Lake Hylia Shop', 0x400007: 'Dark Lake Hylia Shop', 0x400008: 'Dark Lake Hylia Shop', + 0x400009: 'Dark World Lumberjack Shop', 0x40000a: 'Dark World Lumberjack Shop', 0x40000b: 'Dark World Lumberjack Shop', + 0x40000c: 'Village of Outcasts Shop', 0x40000d: 'Village of Outcasts Shop', 0x40000e: 'Village of Outcasts Shop', + 0x40000f: 'Dark World Potion Shop', 0x400010: 'Dark World Potion Shop', 0x400011: 'Dark World Potion Shop', + 0x400012: 'Light World Death Mountain Shop', 0x400013: 'Light World Death Mountain Shop', 0x400014: 'Light World Death Mountain Shop', + 0x400015: 'Kakariko Shop', 0x400016: 'Kakariko Shop', 0x400017: 'Kakariko Shop', + 0x400018: 'Cave Shop (Lake Hylia)', 0x400019: 'Cave Shop (Lake Hylia)', 0x40001a: 'Cave Shop (Lake Hylia)', + 0x40001b: 'Potion Shop', 0x40001c: 'Potion Shop', 0x40001d: 'Potion Shop', + 0x40001e: 'Capacity Upgrade', 0x40001f: 'Capacity Upgrade', 0x400020: 'Capacity Upgrade'} lookup_prizes = {location for location in location_table if location.endswith(" - Prize")} lookup_boss_drops = {location for location in location_table if location.endswith(" - Boss")} \ No newline at end of file diff --git a/Rom.py b/Rom.py index abdb38e2..7aa05913 100644 --- a/Rom.py +++ b/Rom.py @@ -1,7 +1,7 @@ from __future__ import annotations JAP10HASH = '03a63945398191337e896e5771f77173' -RANDOMIZERBASEHASH = '5a607e36a82bbd14180536c8ec3ae49b' +RANDOMIZERBASEHASH = '93538d51eb018955a90181600e3384ba' import io import json @@ -17,7 +17,8 @@ import xxtea import concurrent.futures from typing import Optional -from BaseClasses import CollectionState, ShopType, Region, Location +from BaseClasses import CollectionState, Region, Location +from Shops import ShopType from Dungeons import dungeon_music_addresses from Regions import location_table, old_location_address_to_new_location_address from Text import MultiByteTextMapper, CompressedTextMapper, text_addresses, Credits, TextTable @@ -123,6 +124,9 @@ class LocalRom(object): Patch.create_patch_file(local_path('basepatch.sfc')) return + if not os.path.isfile(local_path('data', 'basepatch.bmbp')): + raise RuntimeError('Base patch unverified. Unable to continue.') + if os.path.isfile(local_path('data', 'basepatch.bmbp')): _, target, buffer = Patch.create_rom_bytes(local_path('data', 'basepatch.bmbp')) if self.verify(buffer): @@ -130,6 +134,7 @@ class LocalRom(object): with open(local_path('basepatch.sfc'), 'wb') as stream: stream.write(buffer) return + raise RuntimeError('Base patch unverified. Unable to continue.') raise RuntimeError('Could not find Base Patch. Unable to continue.') @@ -677,15 +682,13 @@ def patch_rom(world, rom, player, team, enemized): distinguished_prog_bow_loc.item.code = 0x65 # patch items + for location in world.get_locations(): - if location.player != player: + if location.player != player or location.address is None or location.shop_slot: continue itemid = location.item.code if location.item is not None else 0x5A - if location.address is None: - continue - if not location.crystal: if location.item is not None: # Keys in their native dungeon should use the orignal item code for keys @@ -724,6 +727,7 @@ def patch_rom(world, rom, player, team, enemized): for music_address in music_addresses: rom.write_byte(music_address, music) + if world.mapshuffle[player]: rom.write_byte(0x155C9, local_random.choice([0x11, 0x16])) # Randomize GT music too with map shuffle @@ -1547,31 +1551,26 @@ def patch_race_rom(rom, world, player): def write_custom_shops(rom, world, player): - shops = [shop for shop in world.shops if shop.custom and shop.region.player == player] + shops = sorted([shop for shop in world.shops if shop.custom and shop.region.player == player], + key=lambda shop: shop.sram_offset) shop_data = bytearray() items_data = bytearray() - sram_offset = 0 for shop_id, shop in enumerate(shops): if shop_id == len(shops) - 1: shop_id = 0xFF bytes = shop.get_bytes() bytes[0] = shop_id - bytes[-1] = sram_offset - if shop.type == ShopType.TakeAny: - sram_offset += 1 - else: - sram_offset += shop.item_count + bytes[-1] = shop.sram_offset shop_data.extend(bytes) - # [id][item][price-low][price-high][max][repl_id][repl_price-low][repl_price-high] + # [id][item][price-low][price-high][max][repl_id][repl_price-low][repl_price-high][player] for item in shop.inventory: if item is None: break - item_data = [shop_id, ItemFactory(item['item'], player).code] + int16_as_bytes(item['price']) + [ - item['max'], - ItemFactory(item['replacement'], player).code if item['replacement'] else 0xFF] + int16_as_bytes( - item['replacement_price']) + item_data = [shop_id, ItemFactory(item['item'], player).code] + int16_as_bytes(item['price']) + \ + [item['max'], ItemFactory(item['replacement'], player).code if item['replacement'] else 0xFF] + \ + int16_as_bytes(item['replacement_price']) + [0 if item['player'] == player else item['player']] items_data.extend(item_data) rom.write_bytes(0x184800, shop_data) diff --git a/Rules.py b/Rules.py index 02bd8545..99d1c6a1 100644 --- a/Rules.py +++ b/Rules.py @@ -85,7 +85,7 @@ def set_rules(world, player): add_rule(world.get_entrance('Ganons Tower', player), lambda state: state.world.get_entrance('Ganons Tower Ascent', player).can_reach(state), 'or') set_bunny_rules(world, player, world.mode[player] == 'inverted') - + def mirrorless_path_to_castle_courtyard(world, player): # If Agahnim is defeated then the courtyard needs to be accessible without using the mirror for the mirror offset glitch. diff --git a/Shops.py b/Shops.py new file mode 100644 index 00000000..0e94d057 --- /dev/null +++ b/Shops.py @@ -0,0 +1,321 @@ +from __future__ import annotations +from enum import unique, Enum +from typing import List, Union, Optional, Set, NamedTuple, Dict +import logging + +from BaseClasses import Location +from EntranceShuffle import door_addresses +from Items import item_name_groups, item_table, ItemFactory +from Utils import int16_as_bytes + +logger = logging.getLogger("Shops") + + +@unique +class ShopType(Enum): + Shop = 0 + TakeAny = 1 + UpgradeShop = 2 + + +class Shop(): + slots: int = 3 # slot count is not dynamic in asm, however inventory can have None as empty slots + blacklist: Set[str] = set() # items that don't work, todo: actually check against this + type = ShopType.Shop + + def __init__(self, region, room_id: int, shopkeeper_config: int, custom: bool, locked: bool, sram_offset: int): + self.region = region + self.room_id = room_id + self.inventory: List[Optional[dict]] = [None] * self.slots + self.shopkeeper_config = shopkeeper_config + self.custom = custom + self.locked = locked + self.sram_offset = sram_offset + + @property + def item_count(self) -> int: + for x in range(self.slots - 1, -1, -1): # last x is 0 + if self.inventory[x]: + return x + 1 + return 0 + + def get_bytes(self) -> List[int]: + # [id][roomID-low][roomID-high][doorID][zero][shop_config][shopkeeper_config][sram_index] + entrances = self.region.entrances + config = self.item_count + if len(entrances) == 1 and entrances[0].name in door_addresses: + door_id = door_addresses[entrances[0].name][0] + 1 + else: + door_id = 0 + config |= 0x40 # ignore door id + if self.type == ShopType.TakeAny: + config |= 0x80 + elif self.type == ShopType.UpgradeShop: + config |= 0x10 # Alt. VRAM + return [0x00] + int16_as_bytes(self.room_id) + [door_id, 0x00, config, self.shopkeeper_config, 0x00] + + def has_unlimited(self, item: str) -> bool: + for inv in self.inventory: + if inv is None: + continue + if inv['max']: + if inv['replacement'] == item: + return True + elif inv['item'] == item: + return True + + return False + + def has(self, item: str) -> bool: + for inv in self.inventory: + if inv is None: + continue + if inv['item'] == item: + return True + if inv['replacement'] == item: + return True + return False + + def clear_inventory(self): + self.inventory = [None] * self.slots + + def add_inventory(self, slot: int, item: str, price: int, max: int = 0, + replacement: Optional[str] = None, replacement_price: int = 0, create_location: bool = False, + player: int = 0): + self.inventory[slot] = { + 'item': item, + 'price': price, + 'max': max, + 'replacement': replacement, + 'replacement_price': replacement_price, + 'create_location': create_location, + 'player': player + } + + def push_inventory(self, slot: int, item: str, price: int, max: int = 1, player: int = 0): + if not self.inventory[slot]: + raise ValueError("Inventory can't be pushed back if it doesn't exist") + + self.inventory[slot] = { + 'item': item, + 'price': price, + 'max': max, + 'replacement': self.inventory[slot]["item"], + 'replacement_price': self.inventory[slot]["price"], + 'create_location': self.inventory[slot]["create_location"], + 'player': player + } + + def can_push_inventory(self, slot: int): + return self.inventory[slot] and not self.inventory[slot]["replacement"] + + +class TakeAny(Shop): + type = ShopType.TakeAny + + +class UpgradeShop(Shop): + type = ShopType.UpgradeShop + # Potions break due to VRAM flags set in UpgradeShop. + # Didn't check for more things breaking as not much else can be shuffled here currently + blacklist = item_name_groups["Potions"] + + +shop_class_mapping = {ShopType.UpgradeShop: UpgradeShop, + ShopType.Shop: Shop, + ShopType.TakeAny: TakeAny} + + +def FillDisabledShopSlots(world): + shop_slots: Set[Location] = {location for shop_locations in (shop.region.locations for shop in world.shops) + for location in shop_locations if location.shop_slot and location.shop_slot_disabled} + for location in shop_slots: + location.shop_slot_disabled = True + slot_num = int(location.name[-1]) - 1 + shop: Shop = location.parent_region.shop + location.item = ItemFactory(shop.inventory[slot_num]['item'], location.player) + location.item_rule = lambda item: item.name == location.item.name and item.player == location.player + + +def ShopSlotFill(world): + shop_slots: Set[Location] = {location for shop_locations in (shop.region.locations for shop in world.shops) + for location in shop_locations if location.shop_slot} + removed = set() + for location in shop_slots: + slot_num = int(location.name[-1]) - 1 + shop: Shop = location.parent_region.shop + if not shop.can_push_inventory(slot_num) or location.shop_slot_disabled: + removed.add(location) + + if removed: + shop_slots -= removed + + if shop_slots: + from Fill import swap_location_item + # TODO: allow each game to register a blacklist to be used here? + blacklist_words = {"Rupee"} + blacklist_words = {item_name for item_name in item_table if any( + blacklist_word in item_name for blacklist_word in blacklist_words)} + blacklist_words.add("Bee") + candidates_per_sphere = list(list(sphere) for sphere in world.get_spheres()) + + candidate_condition = lambda location: not location.locked and \ + not location.shop_slot and \ + not location.item.name in blacklist_words + + # currently special care needs to be taken so that Shop.region.locations.item is identical to Shop.inventory + # Potentially create Locations as needed and make inventory the only source, to prevent divergence + cumu_weights = [] + + for sphere in candidates_per_sphere: + if cumu_weights: + x = cumu_weights[-1] + else: + x = 0 + cumu_weights.append(len(sphere) + x) + world.random.shuffle(sphere) + + for i, sphere in enumerate(candidates_per_sphere): + current_shop_slots = [location for location in sphere if location.shop_slot and not location.shop_slot_disabled] + if current_shop_slots: + + for location in current_shop_slots: + shop: Shop = location.parent_region.shop + swapping_sphere = world.random.choices(candidates_per_sphere[i:], cum_weights=cumu_weights[i:])[0] + for c in swapping_sphere: # chosen item locations + if candidate_condition(c) and c.item_rule(location.item) and location.item_rule(c.item): + swap_location_item(c, location, check_locked=False) + logger.debug(f'Swapping {c} into {location}:: {location.item}') + break + + else: + # This *should* never happen. But let's fail safely just in case. + logger.warning("Ran out of ShopShuffle Item candidate locations.") + location.shop_slot_disabled = True + continue + item_name = location.item.name + if any(x in item_name for x in ['Single Bomb', 'Single Arrow']): + price = world.random.randrange(1, 7) + elif any(x in item_name for x in ['Arrows', 'Bombs', 'Clock']): + price = world.random.randrange(4, 24) + elif any(x in item_name for x in ['Compass', 'Map', 'Small Key', 'Piece of Heart']): + price = world.random.randrange(10, 30) + else: + price = world.random.randrange(10, 60) + + price *= 5 + shop.push_inventory(int(location.name[-1]) - 1, item_name, price, 1, + location.item.player if location.item.player != location.player else 0) + + +def create_shops(world, player: int): + option = world.shop_shuffle[player] + + player_shop_table = shop_table.copy() + if "w" in option: + player_shop_table["Potion Shop"] = player_shop_table["Potion Shop"]._replace(locked=False) + dynamic_shop_slots = total_dynamic_shop_slots + 3 + else: + dynamic_shop_slots = total_dynamic_shop_slots + + num_slots = min(dynamic_shop_slots, max(0, int(world.shop_shuffle_slots[player]))) # 0 to 30 + single_purchase_slots: List[bool] = [True] * num_slots + [False] * (dynamic_shop_slots - num_slots) + world.random.shuffle(single_purchase_slots) + + if 'g' in option or 'f' in option: + default_shop_table = [i for l in [shop_generation_types[x] for x in ['arrows', 'bombs', 'potions', 'shields', 'bottle'] if not world.retro[player] or x != 'arrows'] for i in l] + new_basic_shop = world.random.sample(default_shop_table, k=3) + new_dark_shop = world.random.sample(default_shop_table, k=3) + for name, shop in player_shop_table.items(): + typ, shop_id, keeper, custom, locked, items, sram_offset = shop + if not locked: + new_items = world.random.sample(default_shop_table, k=3) + if 'f' not in option: + if items == _basic_shop_defaults: + new_items = new_basic_shop + elif items == _dark_world_shop_defaults: + new_items = new_dark_shop + keeper = world.random.choice([0xA0, 0xC1, 0xFF]) + player_shop_table[name] = ShopData(typ, shop_id, keeper, custom, locked, new_items, sram_offset) + if world.mode[player] == "inverted": + player_shop_table["Dark Lake Hylia Shop"] = \ + player_shop_table["Dark Lake Hylia Shop"]._replace(locked=True, items=_inverted_hylia_shop_defaults) + for region_name, (room_id, type, shopkeeper, custom, locked, inventory, sram_offset) in player_shop_table.items(): + region = world.get_region(region_name, player) + shop: Shop = shop_class_mapping[type](region, room_id, shopkeeper, custom, locked, sram_offset) + region.shop = shop + world.shops.append(shop) + for index, item in enumerate(inventory): + shop.add_inventory(index, *item) + if not locked and num_slots: + slot_name = "{} Slot {}".format(region.name, index + 1) + loc = Location(player, slot_name, address=shop_table_by_location[slot_name], + parent=region, hint_text="for sale") + loc.shop_slot = True + loc.locked = True + if single_purchase_slots.pop(): + additional_item = 'Rupees (50)' # world.random.choice(['Rupees (50)', 'Rupees (100)', 'Rupees (300)']) + loc.item = ItemFactory(additional_item, player) + else: + loc.item = ItemFactory('Nothing', player) + loc.shop_slot_disabled = True + shop.region.locations.append(loc) + world.dynamic_locations.append(loc) + world.clear_location_cache() + + +class ShopData(NamedTuple): + room: int + type: ShopType + shopkeeper: int + custom: bool + locked: bool + items: List + sram_offset: int + + +# (type, room_id, shopkeeper, custom, locked, [items], sram_offset) +# item = (item, price, max=0, replacement=None, replacement_price=0) +_basic_shop_defaults = [('Red Potion', 150), ('Small Heart', 10), ('Bombs (10)', 50)] +_dark_world_shop_defaults = [('Red Potion', 150), ('Blue Shield', 50), ('Bombs (10)', 50)] +_inverted_hylia_shop_defaults = [('Blue Potion', 160), ('Blue Shield', 50), ('Bombs (10)', 50)] +shop_table: Dict[str, ShopData] = { + 'Cave Shop (Dark Death Mountain)': ShopData(0x0112, ShopType.Shop, 0xC1, True, False, _basic_shop_defaults, 0), + 'Red Shield Shop': ShopData(0x0110, ShopType.Shop, 0xC1, True, False, + [('Red Shield', 500), ('Bee', 10), ('Arrows (10)', 30)], 3), + 'Dark Lake Hylia Shop': ShopData(0x010F, ShopType.Shop, 0xC1, True, False, _dark_world_shop_defaults, 6), + 'Dark World Lumberjack Shop': ShopData(0x010F, ShopType.Shop, 0xC1, True, False, _dark_world_shop_defaults, 9), + 'Village of Outcasts Shop': ShopData(0x010F, ShopType.Shop, 0xC1, True, False, _dark_world_shop_defaults, 12), + 'Dark World Potion Shop': ShopData(0x010F, ShopType.Shop, 0xC1, True, False, _dark_world_shop_defaults, 15), + 'Light World Death Mountain Shop': ShopData(0x00FF, ShopType.Shop, 0xA0, True, False, _basic_shop_defaults, 18), + 'Kakariko Shop': ShopData(0x011F, ShopType.Shop, 0xA0, True, False, _basic_shop_defaults, 21), + 'Cave Shop (Lake Hylia)': ShopData(0x0112, ShopType.Shop, 0xA0, True, False, _basic_shop_defaults, 24), + 'Potion Shop': ShopData(0x0109, ShopType.Shop, 0xA0, True, True, + [('Red Potion', 120), ('Green Potion', 60), ('Blue Potion', 160)], 27), + 'Capacity Upgrade': ShopData(0x0115, ShopType.UpgradeShop, 0x04, True, True, + [('Bomb Upgrade (+5)', 100, 7), ('Arrow Upgrade (+5)', 100, 7)], 30) +} + +total_shop_slots = len(shop_table) * 3 +total_dynamic_shop_slots = sum(3 for shopname, data in shop_table.items() if not data[4]) # data[4] -> locked + +SHOP_ID_START = 0x400000 +shop_table_by_location_id = {cnt: s for cnt, s in enumerate( + (f"{name} Slot {num}" for name in [key for key, value in sorted(shop_table.items(), key=lambda item: item[1].sram_offset)] + for num in range(1, 4)), start=SHOP_ID_START)} +shop_table_by_location_id[(SHOP_ID_START + total_shop_slots)] = "Old Man Sword Cave" +shop_table_by_location_id[(SHOP_ID_START + total_shop_slots + 1)] = "Take-Any #1" +shop_table_by_location_id[(SHOP_ID_START + total_shop_slots + 2)] = "Take-Any #2" +shop_table_by_location_id[(SHOP_ID_START + total_shop_slots + 3)] = "Take-Any #3" +shop_table_by_location_id[(SHOP_ID_START + total_shop_slots + 4)] = "Take-Any #4" +shop_table_by_location = {y: x for x, y in shop_table_by_location_id.items()} + +shop_generation_types = { + 'arrows': [('Single Arrow', 5), ('Arrows (10)', 50)], + 'bombs': [('Single Bomb', 10), ('Bombs (3)', 30), ('Bombs (10)', 50)], + 'shields': [('Red Shield', 500), ('Blue Shield', 50)], + 'potions': [('Red Potion', 150), ('Green Potion', 90), ('Blue Potion', 190)], + 'discount_potions': [('Red Potion', 120), ('Green Potion', 60), ('Blue Potion', 160)], + 'bottle': [('Small Heart', 10), ('Apple', 50), ('Bee', 10), ('Good Bee', 100), ('Faerie', 100), ('Magic Jar', 100)], + 'time': [('Red Clock', 100), ('Blue Clock', 200), ('Green Clock', 300)], +} diff --git a/Utils.py b/Utils.py index 0c03b31b..5741a3a2 100644 --- a/Utils.py +++ b/Utils.py @@ -13,7 +13,7 @@ class Version(typing.NamedTuple): micro: int -__version__ = "3.6.0" +__version__ = "3.6.1" _version_tuple = tuplize_version(__version__) import os diff --git a/WebHostLib/static/static/playerSettings.json b/WebHostLib/static/static/playerSettings.json index d57fb589..ca9ae329 100644 --- a/WebHostLib/static/static/playerSettings.json +++ b/WebHostLib/static/static/playerSettings.json @@ -477,9 +477,23 @@ "name": "None", "value": "none" }, - { - "name": "Inventory", - "value": "i" + "g": { + "keyString": "shop_shuffle.g", + "friendlyName": "Inventory Generate", + "description": "Generates new default base inventories of overworld and underworld shops.", + "defaultValue": 0 + }, + "f": { + "keyString": "shop_shuffle.f", + "friendlyName": "Full Inventory Generate", + "description": "Generates new base inventories of each individual shop.", + "defaultValue": 0 + }, + "i": { + "keyString": "shop_shuffle.i", + "friendlyName": "Inventory Shuffle", + "description": "Shuffles the inventories of shops between each other.", + "defaultValue": 0 }, { "name": "Prices", @@ -493,9 +507,269 @@ "name": "Inventory and Prices", "value": "ip" }, - { - "name": "Inventory, Prices, and Upgrades", - "value": "ipu" + "uip": { + "keyString": "shop_shuffle.uip", + "friendlyName": "Full Shuffle", + "description": "Shuffles the inventory and randomizes the prices of items in shops. Also distributes capacity upgrades throughout the world.", + "defaultValue": 0 + } + } + }, + "shop_shuffle_slots": { + "keyString": "shop_shuffle_slots", + "friendlyName": "Shop Shuffle Slots", + "description": "How Many Slots in Shops are dedicated to items from the item pool", + "inputType": "range", + "subOptions": { + "0": { + "keyString": "shop_shuffle_slots.0", + "friendlyName": 0, + "description": "0 slots", + "defaultValue": 50 + }, + "15": { + "keyString": "shop_shuffle_slots.3", + "friendlyName": 3, + "description": "3 slots", + "defaultValue": 0 + }, + "20": { + "keyString": "shop_shuffle_slots.6", + "friendlyName": 6, + "description": "6 slots", + "defaultValue": 0 + }, + "30": { + "keyString": "shop_shuffle_slots.12", + "friendlyName": 12, + "description": "12 slots", + "defaultValue": 0 + }, + "40": { + "keyString": "shop_shuffle_slots.96", + "friendlyName": 96, + "description": "96 slots", + "defaultValue": 0 + } + } + }, + "potion_shop_shuffle": { + "keyString": "potion_shop_shuffle", + "friendlyName": "Potion Shop Shuffle Rules", + "description": "Influence on potion shop by shop shuffle options", + "inputType": "range", + "subOptions": { + "none": { + "keyString": "potion_shop_shuffle.none", + "friendlyName": "Vanilla Shops", + "description": "Shop contents are left unchanged, only prices.", + "defaultValue": 50 + }, + "a": { + "keyString": "potion_shop_shuffle.a", + "friendlyName": "Any Items can be shuffled in and out of the shop", + "description": "", + "defaultValue": 0 + } + } + }, + "shuffle_prizes": { + "keyString": "shuffle_prizes", + "friendlyName": "Prize Shuffle", + "description": "Alters the Prizes from pulling, bonking, enemy kills, digging, and hoarders", + "inputType": "range", + "subOptions": { + "none": { + "keyString": "shuffle_prizes.none", + "friendlyName": "None", + "description": "All prizes from pulling, bonking, enemy kills, digging, hoarders are vanilla.", + "defaultValue": 0 + }, + "g": { + "keyString": "shuffle_prizes.g", + "friendlyName": "\"General\" prize shuffle", + "description": "Shuffles the prizes from pulling, enemy kills, digging, hoarders", + "defaultValue": 50 + }, + "b": { + "keyString": "shuffle_prizes.b", + "friendlyName": "Bonk prize shuffle", + "description": "Shuffles the prizes from bonking into trees.", + "defaultValue": 0 + }, + "bg": { + "keyString": "shuffle_prizes.bg", + "friendlyName": "Both", + "description": "Shuffles both of the options.", + "defaultValue": 0 + } + } + }, + "timer": { + "keyString": "timer", + "friendlyName": "Timed Modes", + "description": "Add a timer to the game UI, and cause it to have various effects.", + "inputType": "range", + "subOptions": { + "none": { + "keyString": "timer.none", + "friendlyName": "Disabled", + "description": "No timed mode is applied to the game.", + "defaultValue": 50 + }, + "timed": { + "keyString": "timer.timed", + "friendlyName": "Timed Mode", + "description": "Starts with clock at zero. Green clocks subtract 4 minutes (total 20). Blue clocks subtract 2 minutes (total 10). Red clocks add two minutes (total 10). Winner is the player with the lowest time at the end.", + "defaultValue": 0 + }, + "timed_ohko": { + "keyString": "timer.timed_ohko", + "friendlyName": "Timed OHKO", + "description": "Starts the clock at ten minutes. Green clocks add five minutes (total 25). As long as the clock as at zero, Link will die in one hit.", + "defaultValue": 0 + }, + "ohko": { + "keyString": "timer.ohko", + "friendlyName": "One-Hit KO", + "description": "Timer always at zero. Permanent OHKO.", + "defaultValue": 0 + }, + "timed_countdown": { + "keyString": "timer.timed_countdown", + "friendlyName": "Timed Countdown", + "description": "Starts the clock with forty minutes. Same clocks as timed mode, but if the clock hits zero you lose. You can still keep playing, though.", + "defaultValue": 0 + }, + "display": { + "keyString": "timer.display", + "friendlyName": "Timer Only", + "description": "Displays a timer, but otherwise does not affect gameplay or the item pool.", + "defaultValue": 0 + } + } + }, + "countdown_start_time": { + "keyString": "countdown_start_time", + "friendlyName": "Countdown Starting Time", + "description": "The amount of time, in minutes, to start with in Timed Countdown and Timed OHKO modes.", + "inputType": "range", + "subOptions": { + "0": { + "keyString": "countdown_start_time.0", + "friendlyName": 0, + "description": "Start with no time on the timer. In Timed OHKO mode, start in OHKO mode.", + "defaultValue": 0 + }, + "10": { + "keyString": "countdown_start_time.10", + "friendlyName": 10, + "description": "Start with 10 minutes on the timer.", + "defaultValue": 50 + }, + "20": { + "keyString": "countdown_start_time.20", + "friendlyName": 20, + "description": "Start with 20 minutes on the timer.", + "defaultValue": 0 + }, + "30": { + "keyString": "countdown_start_time.30", + "friendlyName": 30, + "description": "Start with 30 minutes on the timer.", + "defaultValue": 0 + }, + "60": { + "keyString": "countdown_start_time.60", + "friendlyName": 60, + "description": "Start with an hour on the timer.", + "defaultValue": 0 + } + } + }, + "red_clock_time": { + "keyString": "red_clock_time", + "friendlyName": "Red Clock Time", + "description": "The amount of time, in minutes, to add to or subtract from the timer upon picking up a red clock.", + "inputType": "range", + "subOptions": { + "-2": { + "keyString": "red_clock_time.-2", + "friendlyName": -2, + "description": "Subtract 2 minutes from the timer upon picking up a red clock.", + "defaultValue": 0 + }, + "1": { + "keyString": "red_clock_time.1", + "friendlyName": 1, + "description": "Add a minute to the timer upon picking up a red clock.", + "defaultValue": 50 + } + } + }, + "blue_clock_time": { + "keyString": "blue_clock_time", + "friendlyName": "Blue Clock Time", + "description": "The amount of time, in minutes, to add to or subtract from the timer upon picking up a blue clock.", + "inputType": "range", + "subOptions": { + "1": { + "keyString": "blue_clock_time.1", + "friendlyName": 1, + "description": "Add a minute to the timer upon picking up a blue clock.", + "defaultValue": 0 + }, + "2": { + "keyString": "blue_clock_time.2", + "friendlyName": 2, + "description": "Add 2 minutes to the timer upon picking up a blue clock.", + "defaultValue": 50 + } + } + }, + "green_clock_time": { + "keyString": "green_clock_time", + "friendlyName": "Green Clock Time", + "description": "The amount of time, in minutes, to add to or subtract from the timer upon picking up a green clock.", + "inputType": "range", + "subOptions": { + "4": { + "keyString": "green_clock_time.4", + "friendlyName": 4, + "description": "Add 4 minutes to the timer upon picking up a green clock.", + "defaultValue": 50 + }, + "10": { + "keyString": "green_clock_time.10", + "friendlyName": 10, + "description": "Add 10 minutes to the timer upon picking up a green clock.", + "defaultValue": 0 + }, + "15": { + "keyString": "green_clock_time.15", + "friendlyName": 15, + "description": "Add 15 minutes to the timer upon picking up a green clock.", + "defaultValue": 0 + } + } + }, + "glitch_boots": { + "keyString": "glitch_boots", + "friendlyName": "Glitch Boots", + "description": "Start with Pegasus Boots in any glitched logic mode that makes use of them.", + "inputType": "range", + "subOptions": { + "on": { + "keyString": "glitch_boots.on", + "friendlyName": "On", + "description": "Enable glitch boots.", + "defaultValue": 50 + }, + "off": { + "keyString": "glitch_boots.off", + "friendlyName": "Off", + "description": "Disable glitch boots.", + "defaultValue": 0 } ] } diff --git a/data/basepatch.bmbp b/data/basepatch.bmbp index 564edb17..3b1c9e10 100644 Binary files a/data/basepatch.bmbp and b/data/basepatch.bmbp differ diff --git a/playerSettings.yaml b/playerSettings.yaml index 22b40aa9..7e7938ce 100644 --- a/playerSettings.yaml +++ b/playerSettings.yaml @@ -211,14 +211,25 @@ beemizer: # Remove items from the global item pool and replace them with single 2: 0 # 60% of the non-essential item pool is replaced with bee traps, of which 20% could be single bees 3: 0 # 100% of the non-essential item pool is replaced with bee traps, of which 50% could be single bees 4: 0 # 100% of the non-essential item pool is replaced with bee traps +### Shop Settings ### +shop_shuffle_slots: # Maximum amount of shop slots to be filled with regular item pool items (such as Moon Pearl) + 0: 50 + 5: 0 + 15: 0 + 30: 0 shop_shuffle: none: 50 - i: 0 # Shuffle the inventories of the shops around + g: 0 # Generate new default inventories for overworld/underworld shops, and unique shops + f: 0 # Generate new default inventories for every shop independently + i: 0 # Shuffle default inventories of the shops around p: 0 # Randomize the prices of the items in shop inventories u: 0 # Shuffle capacity upgrades into the item pool (and allow them to traverse the multiworld) + w: 0 # Consider witch's hut like any other shop and shuffle/randomize it too ip: 0 # Shuffle inventories and randomize prices + fpu: 0 # Generate new inventories, randomize prices and shuffle capacity upgrades into item pool uip: 0 # Shuffle inventories, randomize prices and shuffle capacity upgrades into the item pool # You can add more combos +### End of Shop Section ### shuffle_prizes: # aka drops none: 0 # do not shuffle prize packs g: 50 # shuffle "general" price packs, as in enemy, tree pull, dig etc. @@ -253,11 +264,6 @@ green_clock_time: # For all timer modes, the amount of time in minutes to gain o # - "Small Keys" # - "Big Keys" # Can be uncommented to use it -# non_local_items: # Force certain items to appear outside your world only, always across the multiworld. Recognizes some group names, like "Swords" -# - "Moon Pearl" -# - "Small Keys" -# - "Big Keys" -# Can be uncommented to use it # startinventory: # Begin the file with the listed items/upgrades # Pegasus Boots: on # Bomb Upgrade (+10): 4 diff --git a/test/dungeons/TestDungeon.py b/test/dungeons/TestDungeon.py index b4a4bb06..6ec685e8 100644 --- a/test/dungeons/TestDungeon.py +++ b/test/dungeons/TestDungeon.py @@ -5,7 +5,8 @@ from Dungeons import create_dungeons, get_dungeon_item_pool from EntranceShuffle import mandatory_connections, connect_simple from ItemPool import difficulties, generate_itempool from Items import ItemFactory -from Regions import create_regions, create_shops +from Regions import create_regions +from Shops import create_shops from Rules import set_rules diff --git a/test/inverted/TestInverted.py b/test/inverted/TestInverted.py index 93a52c57..5ad9de09 100644 --- a/test/inverted/TestInverted.py +++ b/test/inverted/TestInverted.py @@ -4,7 +4,8 @@ from EntranceShuffle import link_inverted_entrances from InvertedRegions import create_inverted_regions from ItemPool import generate_itempool, difficulties from Items import ItemFactory -from Regions import mark_light_world_regions, create_shops +from Regions import mark_light_world_regions +from Shops import create_shops from Rules import set_rules from test.TestBase import TestBase diff --git a/test/inverted_minor_glitches/TestInvertedMinor.py b/test/inverted_minor_glitches/TestInvertedMinor.py index 30f27dce..bbd5ba44 100644 --- a/test/inverted_minor_glitches/TestInvertedMinor.py +++ b/test/inverted_minor_glitches/TestInvertedMinor.py @@ -4,7 +4,8 @@ from EntranceShuffle import link_inverted_entrances from InvertedRegions import create_inverted_regions from ItemPool import generate_itempool, difficulties from Items import ItemFactory -from Regions import mark_light_world_regions, create_shops +from Regions import mark_light_world_regions +from Shops import create_shops from Rules import set_rules from test.TestBase import TestBase diff --git a/test/inverted_owg/TestInvertedOWG.py b/test/inverted_owg/TestInvertedOWG.py index 7cf44c84..bca73b76 100644 --- a/test/inverted_owg/TestInvertedOWG.py +++ b/test/inverted_owg/TestInvertedOWG.py @@ -4,7 +4,8 @@ from EntranceShuffle import link_inverted_entrances from InvertedRegions import create_inverted_regions from ItemPool import generate_itempool, difficulties from Items import ItemFactory -from Regions import mark_light_world_regions, create_shops +from Regions import mark_light_world_regions +from Shops import create_shops from Rules import set_rules from test.TestBase import TestBase diff --git a/test/minor_glitches/TestMinor.py b/test/minor_glitches/TestMinor.py index 23dd387c..4779ca83 100644 --- a/test/minor_glitches/TestMinor.py +++ b/test/minor_glitches/TestMinor.py @@ -4,7 +4,8 @@ from EntranceShuffle import link_entrances from InvertedRegions import mark_dark_world_regions from ItemPool import difficulties, generate_itempool from Items import ItemFactory -from Regions import create_regions, create_shops +from Regions import create_regions +from Shops import create_shops from Rules import set_rules from test.TestBase import TestBase diff --git a/test/owg/TestVanillaOWG.py b/test/owg/TestVanillaOWG.py index bd22db13..4497eba6 100644 --- a/test/owg/TestVanillaOWG.py +++ b/test/owg/TestVanillaOWG.py @@ -4,7 +4,8 @@ from EntranceShuffle import link_entrances from InvertedRegions import mark_dark_world_regions from ItemPool import difficulties, generate_itempool from Items import ItemFactory -from Regions import create_regions, create_shops +from Regions import create_regions +from Shops import create_shops from Rules import set_rules from test.TestBase import TestBase diff --git a/test/shops/TestSram.py b/test/shops/TestSram.py new file mode 100644 index 00000000..0da4e42e --- /dev/null +++ b/test/shops/TestSram.py @@ -0,0 +1,13 @@ +from Shops import shop_table +from test.TestBase import TestBase + + +class TestSram(TestBase): + def testUniqueOffset(self): + sram_ids = set() + for shop_name, shopdata in shop_table.items(): + for x in range(3): + new = shopdata.sram_offset + x + with self.subTest(shop_name, slot=x + 1, offset=new): + self.assertNotIn(new, sram_ids) + sram_ids.add(new) diff --git a/test/shops/__init__.py b/test/shops/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/vanilla/TestVanilla.py b/test/vanilla/TestVanilla.py index e21b8408..41a72f3d 100644 --- a/test/vanilla/TestVanilla.py +++ b/test/vanilla/TestVanilla.py @@ -4,7 +4,8 @@ from EntranceShuffle import link_entrances from InvertedRegions import mark_dark_world_regions from ItemPool import difficulties, generate_itempool from Items import ItemFactory -from Regions import create_regions, create_shops +from Regions import create_regions +from Shops import create_shops from Rules import set_rules from test.TestBase import TestBase