mirror of
https://github.com/MarioSpore/Grinch-AP.git
synced 2025-10-21 20:21:32 -06:00

Make sure that dark lake hylia shop in inverted retains the blue potion, while allowing shop slots on top (potion will always be the leftmost slot, ignoring "i"/"f"/"g") Cull Shop swap Candidates before generating weights, then keep track of updated sphere sizes during swaps. This significantly reduces the chance to run out of candidates, as large clumps of false candidates do not get included in the weight Shop fill is slower with this, as all candidates need to be swept once, instead of on-demand; but this seemed the best way to address the remaining issues.
464 lines
21 KiB
Python
464 lines
21 KiB
Python
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, trap_replaceable
|
|
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")
|
|
|
|
if not self.can_push_inventory(slot):
|
|
logging.warning(f'Warning, there is already an item pushed into this slot.')
|
|
|
|
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:
|
|
location.shop_slot_disabled = True
|
|
removed.add(location)
|
|
|
|
if removed:
|
|
shop_slots -= removed
|
|
|
|
if shop_slots:
|
|
del 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")
|
|
|
|
locations_per_sphere = list(list(sphere) for sphere in world.get_spheres())
|
|
|
|
|
|
|
|
# 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 = []
|
|
shops_per_sphere = []
|
|
candidates_per_sphere = []
|
|
|
|
# sort spheres into piles of valid candidates and shops
|
|
for sphere in locations_per_sphere:
|
|
current_shops_slots = []
|
|
current_candidates = []
|
|
shops_per_sphere.append(current_shops_slots)
|
|
candidates_per_sphere.append(current_candidates)
|
|
for location in sphere:
|
|
if location.shop_slot:
|
|
if not location.shop_slot_disabled:
|
|
current_shops_slots.append(location)
|
|
elif not location.locked and not location.item.name in blacklist_words:
|
|
current_candidates.append(location)
|
|
if cumu_weights:
|
|
x = cumu_weights[-1]
|
|
else:
|
|
x = 0
|
|
cumu_weights.append(len(current_candidates) + x)
|
|
|
|
world.random.shuffle(current_candidates)
|
|
|
|
del(locations_per_sphere)
|
|
|
|
total_spheres = len(candidates_per_sphere)
|
|
|
|
for i, current_shop_slots in enumerate(shops_per_sphere):
|
|
if current_shop_slots:
|
|
candidate_sphere_ids = list(range(i, total_spheres))
|
|
for location in current_shop_slots:
|
|
shop: Shop = location.parent_region.shop
|
|
swapping_sphere_id = world.random.choices(candidate_sphere_ids,
|
|
cum_weights=cumu_weights[i:])[0]
|
|
swapping_sphere: list = candidates_per_sphere[swapping_sphere_id]
|
|
for c in swapping_sphere: # chosen item locations
|
|
if 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
|
|
|
|
# remove candidate
|
|
swapping_sphere.remove(c)
|
|
cumu_weights[swapping_sphere_id] -= 1
|
|
|
|
item_name = location.item.name
|
|
if any(x in item_name for x in ['Single Bomb', 'Single Arrow', 'Piece of Heart']):
|
|
price = world.random.randrange(1, 7)
|
|
elif any(x in item_name for x in ['Arrow', 'Bomb', 'Clock']):
|
|
price = world.random.randrange(2, 14)
|
|
elif any(x in item_name for x in ['Compass', 'Map', 'Small Key', 'Clock', 'Heart']):
|
|
price = world.random.randrange(4, 28)
|
|
else:
|
|
price = world.random.randrange(8, 56)
|
|
|
|
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":
|
|
# make sure that blue potion is available in inverted, special case locked = None; lock when done.
|
|
player_shop_table["Dark Lake Hylia Shop"] = \
|
|
player_shop_table["Dark Lake Hylia Shop"]._replace(items=_inverted_hylia_shop_defaults, locked=None)
|
|
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)
|
|
# special case: allow shop slots, but do not allow overwriting of base inventory behind them
|
|
if locked is None:
|
|
shop.locked = True
|
|
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():
|
|
if world.goal[player] != 'icerodhunt':
|
|
additional_item = 'Rupees (50)'
|
|
else:
|
|
additional_item = 'Nothing'
|
|
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: Optional[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)],
|
|
}
|
|
|
|
|
|
def set_up_shops(world, player: int):
|
|
# TODO: move hard+ mode changes for shields here, utilizing the new shops
|
|
|
|
if world.retro[player]:
|
|
rss = world.get_region('Red Shield Shop', player).shop
|
|
replacement_items = [['Red Potion', 150], ['Green Potion', 75], ['Blue Potion', 200], ['Bombs (10)', 50],
|
|
['Blue Shield', 50], ['Small Heart', 10]] # Can't just replace the single arrow with 10 arrows as retro doesn't need them.
|
|
if world.keyshuffle[player] == "universal":
|
|
replacement_items.append(['Small Key (Universal)', 100])
|
|
replacement_item = world.random.choice(replacement_items)
|
|
rss.add_inventory(2, 'Single Arrow', 80, 1, replacement_item[0], replacement_item[1])
|
|
rss.locked = True
|
|
|
|
if world.keyshuffle[player] == "universal" or world.retro[player]:
|
|
for shop in world.random.sample([s for s in world.shops if
|
|
s.custom and not s.locked and s.type == ShopType.Shop and s.region.player == player],
|
|
5):
|
|
shop.locked = True
|
|
slots = [0, 1, 2]
|
|
world.random.shuffle(slots)
|
|
slots = iter(slots)
|
|
if world.keyshuffle[player] == "universal":
|
|
shop.add_inventory(next(slots), 'Small Key (Universal)', 100)
|
|
if world.retro[player]:
|
|
shop.push_inventory(next(slots), 'Single Arrow', 80)
|
|
|
|
|
|
def shuffle_shops(world, items, player: int):
|
|
option = world.shop_shuffle[player]
|
|
if 'u' in option:
|
|
progressive = world.progressive[player]
|
|
progressive = world.random.choice([True, False]) if progressive == 'random' else progressive == 'on'
|
|
progressive &= world.goal == 'icerodhunt'
|
|
new_items = ["Bomb Upgrade (+5)"] * 6
|
|
new_items.append("Bomb Upgrade (+5)" if progressive else "Bomb Upgrade (+10)")
|
|
|
|
if not world.retro[player]:
|
|
new_items += ["Arrow Upgrade (+5)"] * 6
|
|
new_items.append("Arrow Upgrade (+5)" if progressive else "Arrow Upgrade (+10)")
|
|
|
|
world.random.shuffle(new_items) # Decide what gets tossed randomly if it can't insert everything.
|
|
|
|
capacityshop: Optional[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):
|
|
if item.name in trap_replaceable:
|
|
items[i] = ItemFactory(new_items.pop(), player)
|
|
if not new_items:
|
|
break
|
|
else:
|
|
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))
|
|
|
|
if 'p' in option or 'i' in option:
|
|
shops = []
|
|
upgrade_shops = []
|
|
total_inventory = []
|
|
for shop in world.shops:
|
|
if shop.region.player == player:
|
|
if shop.type == ShopType.UpgradeShop:
|
|
upgrade_shops.append(shop)
|
|
elif shop.type == ShopType.Shop and not shop.locked:
|
|
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!
|
|
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:
|
|
item["price"] = price_adjust(item["price"])
|
|
item['replacement_price'] = price_adjust(item["price"])
|
|
|
|
for item in total_inventory:
|
|
adjust_item(item)
|
|
for shop in upgrade_shops:
|
|
for item in shop.inventory:
|
|
adjust_item(item)
|
|
|
|
if 'i' in option:
|
|
world.random.shuffle(total_inventory)
|
|
|
|
i = 0
|
|
for shop in shops:
|
|
slots = shop.slots
|
|
shop.inventory = total_inventory[i:i + slots]
|
|
i += slots
|