Merge branch 'multishop'

This commit is contained in:
Fabian Dill 2021-01-24 20:30:52 +01:00
commit a6dad66b59
26 changed files with 925 additions and 236 deletions

View File

@ -5,12 +5,11 @@ from enum import Enum, unique
import logging import logging
import json import json
from collections import OrderedDict, Counter, deque 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 secrets
import random import random
from EntranceShuffle import door_addresses, indirect_connections from EntranceShuffle import indirect_connections
from Utils import int16_as_bytes
from Items import item_name_groups 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_available', 30)
set_player_attr('triforce_pieces_required', 20) set_player_attr('triforce_pieces_required', 20)
set_player_attr('shop_shuffle', 'off') set_player_attr('shop_shuffle', 'off')
set_player_attr('shop_shuffle_slots', 0)
set_player_attr('shuffle_prizes', "g") set_player_attr('shuffle_prizes', "g")
set_player_attr('sprite_pool', []) set_player_attr('sprite_pool', [])
set_player_attr('dark_room_logic', "lamp") set_player_attr('dark_room_logic', "lamp")
@ -415,7 +415,7 @@ class World(object):
else: else:
return all((self.has_beaten_game(state, p) for p in range(1, self.players + 1))) 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 starting_state:
if self.has_beaten_game(starting_state): if self.has_beaten_game(starting_state):
return True return True
@ -447,6 +447,87 @@ class World(object):
return False 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): class CollectionState(object):
def __init__(self, parent: World): def __init__(self, parent: World):
@ -980,7 +1061,7 @@ class Dungeon(object):
def __str__(self): def __str__(self):
return self.world.get_name_string_for_object(self) if self.world else f'{self.name} (Player {self.player})' 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): def __init__(self, name, enemizer_name, defeat_rule, player: int):
self.name = name self.name = name
self.enemizer_name = enemizer_name self.enemizer_name = enemizer_name
@ -990,7 +1071,13 @@ class Boss(object):
def can_defeat(self, state) -> bool: def can_defeat(self, state) -> bool:
return self.defeat_rule(state, self.player) 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, def __init__(self, player: int, name: str = '', address=None, crystal: bool = False,
hint_text: Optional[str] = None, parent=None, hint_text: Optional[str] = None, parent=None,
player_address=None): player_address=None):
@ -1003,8 +1090,6 @@ class Location(object):
self.spot_type = 'Location' self.spot_type = 'Location'
self.hint_text: str = hint_text if hint_text else name self.hint_text: str = hint_text if hint_text else name
self.recursion_count = 0 self.recursion_count = 0
self.event = False
self.locked = False
self.always_allow = lambda item, state: False self.always_allow = lambda item, state: False
self.access_rule = lambda state: True self.access_rule = lambda state: True
self.item_rule = lambda item: True self.item_rule = lambda item: True
@ -1029,6 +1114,9 @@ class Location(object):
def __hash__(self): def __hash__(self):
return hash((self.name, self.player)) return hash((self.name, self.player))
def __lt__(self, other):
return (self.player, self.name) < (other.player, other.name)
class Item(object): class Item(object):
@ -1086,105 +1174,6 @@ class Item(object):
class Crystal(Item): class Crystal(Item):
pass 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): class Spoiler(object):
world: World world: World
@ -1247,6 +1236,7 @@ class Spoiler(object):
listed_locations.update(other_locations) listed_locations.update(other_locations)
self.shops = [] self.shops = []
from Shops import ShopType
for shop in self.world.shops: for shop in self.world.shops:
if not shop.custom: if not shop.custom:
continue continue
@ -1257,6 +1247,10 @@ class Spoiler(object):
if item is None: if item is None:
continue continue
shopdata['item_{}'.format(index)] = "{}{}".format(item['item'], item['price']) if item['price'] else item['item'] 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: if item['max'] == 0:
continue continue
shopdata['item_{}'.format(index)] += " x {}".format(item['max']) 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_available': self.world.triforce_pieces_available,
'triforce_pieces_required': self.world.triforce_pieces_required, 'triforce_pieces_required': self.world.triforce_pieces_required,
'shop_shuffle': self.world.shop_shuffle, 'shop_shuffle': self.world.shop_shuffle,
'shop_shuffle_slots': self.world.shop_shuffle_slots,
'shuffle_prizes': self.world.shuffle_prizes, 'shuffle_prizes': self.world.shuffle_prizes,
'sprite_pool': self.world.sprite_pool, 'sprite_pool': self.world.sprite_pool,
'restrict_dungeon_item_on_boss': self.world.restrict_dungeon_item_on_boss 'restrict_dungeon_item_on_boss': self.world.restrict_dungeon_item_on_boss

View File

@ -144,7 +144,7 @@ def fill_dungeons_restrictive(world):
# sort in the order Big Key, Small Key, Other before placing dungeon items # sort in the order Big Key, Small Key, Other before placing dungeon items
sort_order = {"BigKey": 3, "SmallKey": 2} sort_order = {"BigKey": 3, "SmallKey": 2}
dungeon_items.sort(key=lambda item: sort_order.get(item.type, 1)) 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], dungeon_music_addresses = {'Eastern Palace - Prize': [0x1559A],

View File

@ -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('--beemizer', default=defval(0), type=lambda value: min(max(int(value), 0), 4))
parser.add_argument('--shop_shuffle', default='', help='''\ parser.add_argument('--shop_shuffle', default='', help='''\
combine letters for options: 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 p: randomize the prices of the items in shop inventories
u: shuffle capacity upgrades into the item pool 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('--shuffle_prizes', default=defval('g'), choices=['', 'g', 'b', 'gb'])
parser.add_argument('--sprite_pool', help='''\ 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', 'shufflebosses', 'enemy_shuffle', 'enemy_health', 'enemy_damage', 'shufflepots',
'ow_palettes', 'uw_palettes', 'sprite', 'disablemusic', 'quickswap', 'fastmenu', 'heartcolor', 'ow_palettes', 'uw_palettes', 'sprite', 'disablemusic', 'quickswap', 'fastmenu', 'heartcolor',
'heartbeep', "skip_progression_balancing", "triforce_pieces_available", '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", "plando_items", "plando_texts", "plando_connections",
'remote_items', 'progressive', 'dungeon_counters', 'glitch_boots', 'killable_thieves', 'remote_items', 'progressive', 'dungeon_counters', 'glitch_boots', 'killable_thieves',
'tile_shuffle', 'bush_shuffle', 'shuffle_prizes', 'sprite_pool', 'dark_room_logic', 'tile_shuffle', 'bush_shuffle', 'shuffle_prizes', 'sprite_pool', 'dark_room_logic',

27
Fill.py
View File

@ -1,7 +1,7 @@
import logging import logging
import typing import typing
from BaseClasses import CollectionState, PlandoItem from BaseClasses import CollectionState, PlandoItem, Location
from Items import ItemFactory from Items import ItemFactory
from Regions import key_drop_data from Regions import key_drop_data
@ -10,7 +10,8 @@ class FillError(RuntimeError):
pass 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(): def sweep_from_pool():
new_state = base_state.copy() new_state = base_state.copy()
for item in itempool: 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)}') f'Already placed {len(placements)}: {", ".join(str(place) for place in placements)}')
world.push_item(spot_to_fill, item_to_place, False) world.push_item(spot_to_fill, item_to_place, False)
if lock:
spot_to_fill.locked = True
locations.remove(spot_to_fill) locations.remove(spot_to_fill)
placements.append(spot_to_fill) placements.append(spot_to_fill)
spot_to_fill.event = True 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) fill_locations.remove(spot_to_fill)
world.random.shuffle(fill_locations) world.random.shuffle(fill_locations)
prioitempool, fill_locations = fast_fill(world, prioitempool, fill_locations) prioitempool, fill_locations = fast_fill(world, prioitempool, fill_locations)
restitempool, fill_locations = fast_fill(world, restitempool, fill_locations) restitempool, fill_locations = fast_fill(world, restitempool, fill_locations)
@ -244,6 +248,7 @@ def flood_items(world):
itempool.remove(item_to_place) itempool.remove(item_to_place)
break break
def balance_multiworld_progression(world): def balance_multiworld_progression(world):
balanceable_players = {player for player in range(1, world.players + 1) if world.progression_balancing[player]} balanceable_players = {player for player in range(1, world.players + 1) if world.progression_balancing[player]}
if not balanceable_players: if not balanceable_players:
@ -331,7 +336,8 @@ def balance_multiworld_progression(world):
replacement_locations.insert(0, new_location) replacement_locations.insert(0, new_location)
new_location = replacement_locations.pop() 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 new_location.event, old_location.event = True, False
logging.debug(f"Progression balancing moved {new_location.item} to {new_location}, " logging.debug(f"Progression balancing moved {new_location.item} to {new_location}, "
f"displacing {old_location.item} in {old_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.') 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): def distribute_planned(world):
world_name_lookup = {world.player_names[player_id][0]: player_id for player_id in world.player_ids} 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 placement: PlandoItem
for placement in world.plando_items[player]: for placement in world.plando_items[player]:
if placement.location in key_drop_data: 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 continue
item = ItemFactory(placement.item, player) item = ItemFactory(placement.item, player)
target_world: int = placement.world target_world: int = placement.world

View File

@ -1,7 +1,8 @@
from collections import namedtuple from collections import namedtuple
import logging 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 Bosses import place_bosses
from Dungeons import get_dungeon_item_pool from Dungeons import get_dungeon_item_pool
from EntranceShuffle import connect_entrance 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. world.random.shuffle(new_items) # Decide what gets tossed randomly if it can't insert everything.
capacityshop: Shop = None
for shop in world.shops: for shop in world.shops:
if shop.type == ShopType.UpgradeShop and shop.region.player == player and \ if shop.type == ShopType.UpgradeShop and shop.region.player == player and \
shop.region.name == "Capacity Upgrade": shop.region.name == "Capacity Upgrade":
shop.clear_inventory() shop.clear_inventory()
capacityshop = shop
if world.goal[player] != 'icerodhunt': if world.goal[player] != 'icerodhunt':
for i, item in enumerate(items): for i, item in enumerate(items):
@ -472,7 +475,13 @@ def shuffle_shops(world, items, player: int):
if not new_items: if not new_items:
break break
else: 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: else:
for item in new_items: for item in new_items:
world.push_precollected(ItemFactory(item, player)) world.push_precollected(ItemFactory(item, player))
@ -485,14 +494,19 @@ def shuffle_shops(world, items, player: int):
if shop.region.player == player: if shop.region.player == player:
if shop.type == ShopType.UpgradeShop: if shop.type == ShopType.UpgradeShop:
upgrade_shops.append(shop) upgrade_shops.append(shop)
elif shop.type == ShopType.Shop and shop.region.name != 'Potion Shop': elif shop.type == ShopType.Shop:
shops.append(shop) if shop.region.name == 'Potion Shop' and not 'w' in option:
total_inventory.extend(shop.inventory) # don't modify potion shop
pass
else:
shops.append(shop)
total_inventory.extend(shop.inventory)
if 'p' in option: if 'p' in option:
def price_adjust(price: int) -> int: def price_adjust(price: int) -> int:
# it is important that a base price of 0 always returns 0 as new price! # 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): def adjust_item(item):
if item: if item:
@ -507,6 +521,7 @@ def shuffle_shops(world, items, player: int):
if 'i' in option: if 'i' in option:
world.random.shuffle(total_inventory) world.random.shuffle(total_inventory)
i = 0 i = 0
for shop in shops: for shop in shops:
slots = shop.slots slots = shop.slots
@ -548,7 +563,7 @@ def set_up_take_anys(world, player):
entrance = world.get_region(reg, player).entrances[0] entrance = world.get_region(reg, player).entrances[0]
connect_entrance(world, entrance.name, old_man_take_any.name, player) connect_entrance(world, entrance.name, old_man_take_any.name, player)
entrance.target = 0x58 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) world.shops.append(old_man_take_any.shop)
swords = [item for item in world.itempool if item.type == 'Sword' and item.player == player] 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] entrance = world.get_region(reg, player).entrances[0]
connect_entrance(world, entrance.name, take_any.name, player) connect_entrance(world, entrance.name, take_any.name, player)
entrance.target = target 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) world.shops.append(take_any.shop)
take_any.shop.add_inventory(0, 'Blue Potion', 0, 0) take_any.shop.add_inventory(0, 'Blue Potion', 0, 0)
take_any.shop.add_inventory(1, 'Boss Heart Container', 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: if item is None:
continue continue
if item['create_location']: 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) shop.region.locations.append(loc)
world.dynamic_locations.append(loc) world.dynamic_locations.append(loc)
world.clear_location_cache() world.clear_location_cache()
world.push_item(loc, ItemFactory(item['item'], player), False) world.push_item(loc, ItemFactory(item['item'], player), False)
loc.shop_slot = True
loc.event = True loc.event = True
loc.locked = True loc.locked = True
@ -611,7 +627,7 @@ def fill_prizes(world, attempts=15):
prize_locs = list(empty_crystal_locations) prize_locs = list(empty_crystal_locations)
world.random.shuffle(prizepool) world.random.shuffle(prizepool)
world.random.shuffle(prize_locs) 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: except FillError as e:
logging.getLogger('').exception("Failed to place dungeon prizes (%s). Will retry %s more times", e, logging.getLogger('').exception("Failed to place dungeon prizes (%s). Will retry %s more times", e,
attempts - attempt) attempts - attempt)

View File

@ -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'), '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'), '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'), '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'), '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'), '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'), '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'),

54
Main.py
View File

@ -9,9 +9,10 @@ import time
import zlib import zlib
import concurrent.futures 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 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 InvertedRegions import create_inverted_regions, mark_dark_world_regions
from EntranceShuffle import link_entrances, link_inverted_entrances, plando_connect 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 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_available = args.triforce_pieces_available.copy()
world.triforce_pieces_required = args.triforce_pieces_required.copy() world.triforce_pieces_required = args.triforce_pieces_required.copy()
world.shop_shuffle = args.shop_shuffle.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.progression_balancing = {player: not balance for player, balance in args.skip_progression_balancing.items()}
world.shuffle_prizes = args.shuffle_prizes.copy() world.shuffle_prizes = args.shuffle_prizes.copy()
world.sprite_pool = args.sprite_pool.copy() world.sprite_pool = args.sprite_pool.copy()
@ -209,8 +211,14 @@ def main(args, seed=None):
if world.players > 1: if world.players > 1:
balance_multiworld_progression(world) balance_multiworld_progression(world)
logger.info("Filling Shop Slots")
ShopSlotFill(world)
logger.info('Patching ROM.') logger.info('Patching ROM.')
outfilebase = 'BM_%s' % (args.outputname if args.outputname else world.seed) outfilebase = 'BM_%s' % (args.outputname if args.outputname else world.seed)
rom_names = [] rom_names = []
@ -245,7 +253,6 @@ def main(args, seed=None):
args.fastmenu[player], args.disablemusic[player], args.sprite[player], args.fastmenu[player], args.disablemusic[player], args.sprite[player],
palettes_options, world, player, True) palettes_options, world, player, True)
mcsb_name = '' mcsb_name = ''
if all([world.mapshuffle[player], world.compassshuffle[player], world.keyshuffle[player], if all([world.mapshuffle[player], world.compassshuffle[player], world.keyshuffle[player],
world.bigkeyshuffle[player]]): world.bigkeyshuffle[player]]):
@ -281,7 +288,7 @@ def main(args, seed=None):
"progressive": world.progressive, # A "progressive": world.progressive, # A
"hints": 'True' if world.hints[player] else 'False' # B "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' % ( 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 # 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 # _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() pool = concurrent.futures.ThreadPoolExecutor()
multidata_task = None 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: if not args.suppress_rom:
rom_futures = [] rom_futures = []
@ -329,13 +336,14 @@ def main(args, seed=None):
return get_entrance_to_region(entrance.parent_region) return get_entrance_to_region(entrance.parent_region)
# collect ER hint info # 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 from Regions import RegionType
for region in world.regions: for region in world.regions:
if region.player in er_hint_data and region.locations: if region.player in er_hint_data and region.locations:
main_entrance = get_entrance_to_region(region) main_entrance = get_entrance_to_region(region)
for location in region.locations: for location in region.locations:
if type(location.address) == int: # skips events and crystals 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: if lookup_vanilla_location_to_entrance[location.address] != main_entrance.name:
er_hint_data[region.player][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]["Dark World"].append(location.address)
checks_in_area[location.player]["Total"] += 1 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)] precollected_items = [[] for player in range(world.players)]
for item in world.precollected_items: for item in world.precollected_items:
precollected_items[item.player - 1].append(item.code) precollected_items[item.player - 1].append(item.code)
FillDisabledShopSlots(world)
def write_multidata(roms): def write_multidata(roms):
for future in roms: for future in roms:
rom_name = future.result() rom_name = future.result()
@ -378,7 +402,11 @@ def main(args, seed=None):
multidatatags.append("Spoiler") multidatatags.append("Spoiler")
if not args.skip_playthrough: if not args.skip_playthrough:
multidatatags.append("Play through") 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, multidata = zlib.compress(json.dumps({"names": parsed_names,
# backwards compat for < 2.4.1 # backwards compat for < 2.4.1
"roms": [(slot, team, list(name.encode())) "roms": [(slot, team, list(name.encode()))
@ -389,7 +417,7 @@ def main(args, seed=None):
"locations": [((location.address, location.player), "locations": [((location.address, location.player),
(location.item.code, location.item.player)) (location.item.code, location.item.player))
for location in world.get_filled_locations() if 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, "checks_in_area": checks_in_area,
"server_options": get_options()["server_options"], "server_options": get_options()["server_options"],
"er_hint_data": er_hint_data, "er_hint_data": er_hint_data,
@ -403,8 +431,11 @@ def main(args, seed=None):
f.write(multidata) f.write(multidata)
multidata_task = pool.submit(write_multidata, rom_futures) multidata_task = pool.submit(write_multidata, rom_futures)
if not check_beatability_task.result(): if not check_accessibility_task.result():
raise Exception("Game appears unbeatable. Aborting.") 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: if not args.skip_playthrough:
logger.info('Calculating playthrough.') logger.info('Calculating playthrough.')
create_playthrough(world) create_playthrough(world)
@ -458,6 +489,7 @@ def copy_world(world):
ret.shufflepots = world.shufflepots.copy() ret.shufflepots = world.shufflepots.copy()
ret.shuffle_prizes = world.shuffle_prizes.copy() ret.shuffle_prizes = world.shuffle_prizes.copy()
ret.shop_shuffle = world.shop_shuffle.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.dark_room_logic = world.dark_room_logic.copy()
ret.restrict_dungeon_item_on_boss = world.restrict_dungeon_item_on_boss.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: if region.shop:
new_reg.shop = region.shop.__class__(new_reg, region.shop.room_id, region.shop.shopkeeper_config, 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) ret.shops.append(new_reg.shop)
for location in world.dynamic_locations: for location in world.dynamic_locations:

View File

@ -18,6 +18,7 @@ import shutil
from random import randrange from random import randrange
import Shops
from Utils import get_item_name_from_id, get_location_name_from_address, ReceivedItem from Utils import get_item_name_from_id, get_location_name_from_address, ReceivedItem
exit_func = atexit.register(input, "Press enter to close.") exit_func = atexit.register(input, "Press enter to close.")
@ -93,7 +94,6 @@ class Context():
self.player_names: typing.Dict[int: str] = {} self.player_names: typing.Dict[int: str] = {}
self.locations_recognized = set() self.locations_recognized = set()
self.locations_checked = set() self.locations_checked = set()
self.unsafe_locations_checked = set()
self.locations_scouted = set() self.locations_scouted = set()
self.items_received = [] self.items_received = []
self.items_missing = [] self.items_missing = []
@ -104,7 +104,6 @@ class Context():
self.prev_rom = None self.prev_rom = None
self.auth = None self.auth = None
self.found_items = found_items self.found_items = found_items
self.send_unsafe = False
self.finished_game = False self.finished_game = False
self.slow_mode = 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_LOCATION_ADDR = SAVEDATA_START + 0x4D8 # 1 byte
SCOUTREPLY_ITEM_ADDR = SAVEDATA_START + 0x4D9 # 1 byte SCOUTREPLY_ITEM_ADDR = SAVEDATA_START + 0x4D9 # 1 byte
SCOUTREPLY_PLAYER_ADDR = SAVEDATA_START + 0x4DA # 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), location_table_uw = {"Blind's Hideout - Top": (0x11d, 0x10),
"Blind's Hideout - Left": (0x11d, 0x20), "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') raise Exception('Connection refused by the multiworld host, no reason provided')
elif cmd == 'Connected': 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", "default", ctx.server_address)
Utils.persistent_store("servers", ctx.rom, ctx.server_address) Utils.persistent_store("servers", ctx.rom, ctx.server_address)
ctx.team, ctx.slot = args[0] ctx.team, ctx.slot = args[0]
@ -1131,15 +1132,6 @@ class ClientCommandProcessor(CommandProcessor):
else: else:
self.output("Web UI was never started.") 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): def default(self, raw: str):
asyncio.create_task(self.ctx.send_msgs([['Say', raw]])) asyncio.create_task(self.ctx.send_msgs([['Say', raw]]))
@ -1169,11 +1161,11 @@ async def track_locations(ctx : Context, roomid, roomdata):
new_locations = [] new_locations = []
def new_check(location): def new_check(location):
ctx.unsafe_locations_checked.add(location) ctx.locations_checked.add(location)
check = None check = None
if ctx.items_checked is 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: else:
items_total = len(ctx.items_missing) + len(ctx.items_checked) items_total = len(ctx.items_missing) + len(ctx.items_checked)
if location in ctx.items_missing or location in 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) logger.info(check)
ctx.ui_node.send_location_check(ctx, location) 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(): for location, (loc_roomid, loc_mask) in location_table_uw.items():
try: 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) new_check(location)
except Exception as e: except Exception as e:
logger.exception(f"Exception: {e}") logger.exception(f"Exception: {e}")
@ -1195,7 +1199,7 @@ async def track_locations(ctx : Context, roomid, roomdata):
uw_end = 0 uw_end = 0
uw_unchecked = {} uw_unchecked = {}
for location, (roomid, mask) in location_table_uw.items(): 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_unchecked[location] = (roomid, mask)
uw_begin = min(uw_begin, roomid) uw_begin = min(uw_begin, roomid)
uw_end = max(uw_end, roomid + 1) uw_end = max(uw_end, roomid + 1)
@ -1212,7 +1216,7 @@ async def track_locations(ctx : Context, roomid, roomdata):
ow_end = 0 ow_end = 0
ow_unchecked = {} ow_unchecked = {}
for location, screenid in location_table_ow.items(): 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_unchecked[location] = screenid
ow_begin = min(ow_begin, screenid) ow_begin = min(ow_begin, screenid)
ow_end = max(ow_end, screenid + 1) 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: if ow_data[screenid - ow_begin] & 0x40 != 0:
new_check(location) 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) npc_data = await snes_read(ctx, SAVEDATA_START + 0x410, 2)
if npc_data is not None: if npc_data is not None:
npc_value = npc_data[0] | (npc_data[1] << 8) npc_value = npc_data[0] | (npc_data[1] << 8)
for location, mask in location_table_npc.items(): 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) 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) misc_data = await snes_read(ctx, SAVEDATA_START + 0x3c6, 4)
if misc_data is not None: if misc_data is not None:
for location, (offset, mask) in location_table_misc.items(): for location, (offset, mask) in location_table_misc.items():
assert(0x3c6 <= offset <= 0x3c9) 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) new_check(location)
for location in ctx.unsafe_locations_checked: for location in ctx.locations_checked:
if (location in ctx.items_missing and location not in ctx.locations_checked) or ctx.send_unsafe: try:
ctx.locations_checked.add(location) my_id = Regions.lookup_name_to_id.get(location, Shops.shop_table_by_location.get(location, -1))
new_locations.append(Regions.lookup_name_to_id[location]) new_locations.append(my_id)
except Exception as e:
print(e)
logger.info(f"Exception: {e}")
await ctx.send_msgs([['LocationChecks', new_locations]]) await ctx.send_msgs([['LocationChecks', new_locations]])
@ -1274,7 +1282,6 @@ async def game_watcher(ctx : Context):
ctx.rom = rom.decode() ctx.rom = rom.decode()
if not ctx.prev_rom or ctx.prev_rom != ctx.rom: if not ctx.prev_rom or ctx.prev_rom != ctx.rom:
ctx.locations_checked = set() ctx.locations_checked = set()
ctx.unsafe_locations_checked = set()
ctx.locations_scouted = set() ctx.locations_scouted = set()
ctx.prev_rom = ctx.rom ctx.prev_rom = ctx.rom

View File

@ -405,6 +405,8 @@ def roll_settings(weights, plando_options: typing.Set[str] = frozenset(("bosses"
# change minimum to required pieces to avoid problems # 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.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, '') ret.shop_shuffle = get_choice('shop_shuffle', weights, '')
if not ret.shop_shuffle: if not ret.shop_shuffle:
ret.shop_shuffle = '' ret.shop_shuffle = ''

View File

@ -1,7 +1,8 @@
import collections import collections
import typing 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): def create_regions(world, player):
@ -365,38 +366,6 @@ def mark_light_world_regions(world, player: int):
queue.append(exit.connected_region) 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 = { old_location_address_to_new_location_address = {
0x2eb18: 0x18001b, # Bottle Merchant 0x2eb18: 0x18001b, # Bottle Merchant
@ -703,10 +672,13 @@ location_table: typing.Dict[str,
'Turtle Rock - Prize': ( 'Turtle Rock - Prize': (
[0x120A7, 0x53F24, 0x53F25, 0x18005C, 0x180079, 0xC708], None, True, 'Turtle Rock')} [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 = {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 = {**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 = {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 = {**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', 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', 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', 0x140064: 'Misery Mire',
0x140058: 'Turtle Rock', 0x140007: 'Dark Death Mountain Ledge (West)', 0x140058: 'Turtle Rock', 0x140007: 'Dark Death Mountain Ledge (West)',
0x140040: 'Ganons Tower', 0x140043: 'Ganons Tower', 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_prizes = {location for location in location_table if location.endswith(" - Prize")}
lookup_boss_drops = {location for location in location_table if location.endswith(" - Boss")} lookup_boss_drops = {location for location in location_table if location.endswith(" - Boss")}

35
Rom.py
View File

@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
JAP10HASH = '03a63945398191337e896e5771f77173' JAP10HASH = '03a63945398191337e896e5771f77173'
RANDOMIZERBASEHASH = '5a607e36a82bbd14180536c8ec3ae49b' RANDOMIZERBASEHASH = '93538d51eb018955a90181600e3384ba'
import io import io
import json import json
@ -17,7 +17,8 @@ import xxtea
import concurrent.futures import concurrent.futures
from typing import Optional 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 Dungeons import dungeon_music_addresses
from Regions import location_table, old_location_address_to_new_location_address from Regions import location_table, old_location_address_to_new_location_address
from Text import MultiByteTextMapper, CompressedTextMapper, text_addresses, Credits, TextTable from Text import MultiByteTextMapper, CompressedTextMapper, text_addresses, Credits, TextTable
@ -123,6 +124,9 @@ class LocalRom(object):
Patch.create_patch_file(local_path('basepatch.sfc')) Patch.create_patch_file(local_path('basepatch.sfc'))
return 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')): if os.path.isfile(local_path('data', 'basepatch.bmbp')):
_, target, buffer = Patch.create_rom_bytes(local_path('data', 'basepatch.bmbp')) _, target, buffer = Patch.create_rom_bytes(local_path('data', 'basepatch.bmbp'))
if self.verify(buffer): if self.verify(buffer):
@ -130,6 +134,7 @@ class LocalRom(object):
with open(local_path('basepatch.sfc'), 'wb') as stream: with open(local_path('basepatch.sfc'), 'wb') as stream:
stream.write(buffer) stream.write(buffer)
return return
raise RuntimeError('Base patch unverified. Unable to continue.')
raise RuntimeError('Could not find Base Patch. 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 distinguished_prog_bow_loc.item.code = 0x65
# patch items # patch items
for location in world.get_locations(): for location in world.get_locations():
if location.player != player: if location.player != player or location.address is None or location.shop_slot:
continue continue
itemid = location.item.code if location.item is not None else 0x5A itemid = location.item.code if location.item is not None else 0x5A
if location.address is None:
continue
if not location.crystal: if not location.crystal:
if location.item is not None: if location.item is not None:
# Keys in their native dungeon should use the orignal item code for keys # 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: for music_address in music_addresses:
rom.write_byte(music_address, music) rom.write_byte(music_address, music)
if world.mapshuffle[player]: if world.mapshuffle[player]:
rom.write_byte(0x155C9, local_random.choice([0x11, 0x16])) # Randomize GT music too with map shuffle 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): 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() shop_data = bytearray()
items_data = bytearray() items_data = bytearray()
sram_offset = 0
for shop_id, shop in enumerate(shops): for shop_id, shop in enumerate(shops):
if shop_id == len(shops) - 1: if shop_id == len(shops) - 1:
shop_id = 0xFF shop_id = 0xFF
bytes = shop.get_bytes() bytes = shop.get_bytes()
bytes[0] = shop_id bytes[0] = shop_id
bytes[-1] = sram_offset bytes[-1] = shop.sram_offset
if shop.type == ShopType.TakeAny:
sram_offset += 1
else:
sram_offset += shop.item_count
shop_data.extend(bytes) 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: for item in shop.inventory:
if item is None: if item is None:
break break
item_data = [shop_id, ItemFactory(item['item'], player).code] + int16_as_bytes(item['price']) + [ item_data = [shop_id, ItemFactory(item['item'], player).code] + int16_as_bytes(item['price']) + \
item['max'], [item['max'], ItemFactory(item['replacement'], player).code if item['replacement'] else 0xFF] + \
ItemFactory(item['replacement'], player).code if item['replacement'] else 0xFF] + int16_as_bytes( int16_as_bytes(item['replacement_price']) + [0 if item['player'] == player else item['player']]
item['replacement_price'])
items_data.extend(item_data) items_data.extend(item_data)
rom.write_bytes(0x184800, shop_data) rom.write_bytes(0x184800, shop_data)

View File

@ -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') 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') set_bunny_rules(world, player, world.mode[player] == 'inverted')
def mirrorless_path_to_castle_courtyard(world, player): 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. # If Agahnim is defeated then the courtyard needs to be accessible without using the mirror for the mirror offset glitch.

321
Shops.py Normal file
View File

@ -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)],
}

View File

@ -13,7 +13,7 @@ class Version(typing.NamedTuple):
micro: int micro: int
__version__ = "3.6.0" __version__ = "3.6.1"
_version_tuple = tuplize_version(__version__) _version_tuple = tuplize_version(__version__)
import os import os

View File

@ -477,9 +477,23 @@
"name": "None", "name": "None",
"value": "none" "value": "none"
}, },
{ "g": {
"name": "Inventory", "keyString": "shop_shuffle.g",
"value": "i" "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", "name": "Prices",
@ -493,9 +507,269 @@
"name": "Inventory and Prices", "name": "Inventory and Prices",
"value": "ip" "value": "ip"
}, },
{ "uip": {
"name": "Inventory, Prices, and Upgrades", "keyString": "shop_shuffle.uip",
"value": "ipu" "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
} }
] ]
} }

Binary file not shown.

View File

@ -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 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 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 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: shop_shuffle:
none: 50 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 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) 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 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 uip: 0 # Shuffle inventories, randomize prices and shuffle capacity upgrades into the item pool
# You can add more combos # You can add more combos
### End of Shop Section ###
shuffle_prizes: # aka drops shuffle_prizes: # aka drops
none: 0 # do not shuffle prize packs none: 0 # do not shuffle prize packs
g: 50 # shuffle "general" price packs, as in enemy, tree pull, dig etc. 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" # - "Small Keys"
# - "Big Keys" # - "Big Keys"
# Can be uncommented to use it # 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 # startinventory: # Begin the file with the listed items/upgrades
# Pegasus Boots: on # Pegasus Boots: on
# Bomb Upgrade (+10): 4 # Bomb Upgrade (+10): 4

View File

@ -5,7 +5,8 @@ from Dungeons import create_dungeons, get_dungeon_item_pool
from EntranceShuffle import mandatory_connections, connect_simple from EntranceShuffle import mandatory_connections, connect_simple
from ItemPool import difficulties, generate_itempool from ItemPool import difficulties, generate_itempool
from Items import ItemFactory 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 Rules import set_rules

View File

@ -4,7 +4,8 @@ from EntranceShuffle import link_inverted_entrances
from InvertedRegions import create_inverted_regions from InvertedRegions import create_inverted_regions
from ItemPool import generate_itempool, difficulties from ItemPool import generate_itempool, difficulties
from Items import ItemFactory 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 Rules import set_rules
from test.TestBase import TestBase from test.TestBase import TestBase

View File

@ -4,7 +4,8 @@ from EntranceShuffle import link_inverted_entrances
from InvertedRegions import create_inverted_regions from InvertedRegions import create_inverted_regions
from ItemPool import generate_itempool, difficulties from ItemPool import generate_itempool, difficulties
from Items import ItemFactory 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 Rules import set_rules
from test.TestBase import TestBase from test.TestBase import TestBase

View File

@ -4,7 +4,8 @@ from EntranceShuffle import link_inverted_entrances
from InvertedRegions import create_inverted_regions from InvertedRegions import create_inverted_regions
from ItemPool import generate_itempool, difficulties from ItemPool import generate_itempool, difficulties
from Items import ItemFactory 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 Rules import set_rules
from test.TestBase import TestBase from test.TestBase import TestBase

View File

@ -4,7 +4,8 @@ from EntranceShuffle import link_entrances
from InvertedRegions import mark_dark_world_regions from InvertedRegions import mark_dark_world_regions
from ItemPool import difficulties, generate_itempool from ItemPool import difficulties, generate_itempool
from Items import ItemFactory 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 Rules import set_rules
from test.TestBase import TestBase from test.TestBase import TestBase

View File

@ -4,7 +4,8 @@ from EntranceShuffle import link_entrances
from InvertedRegions import mark_dark_world_regions from InvertedRegions import mark_dark_world_regions
from ItemPool import difficulties, generate_itempool from ItemPool import difficulties, generate_itempool
from Items import ItemFactory 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 Rules import set_rules
from test.TestBase import TestBase from test.TestBase import TestBase

13
test/shops/TestSram.py Normal file
View File

@ -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)

0
test/shops/__init__.py Normal file
View File

View File

@ -4,7 +4,8 @@ from EntranceShuffle import link_entrances
from InvertedRegions import mark_dark_world_regions from InvertedRegions import mark_dark_world_regions
from ItemPool import difficulties, generate_itempool from ItemPool import difficulties, generate_itempool
from Items import ItemFactory 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 Rules import set_rules
from test.TestBase import TestBase from test.TestBase import TestBase