From f2d0d1e8955c8dbd364706fb47dade34ca895765 Mon Sep 17 00:00:00 2001 From: Aaron Wagener Date: Mon, 24 Jul 2023 19:41:20 -0500 Subject: [PATCH] The Messenger: Improve the shopping experience (#2029) * The Messenger: Don't generate Figurines * The Messenger: add prerequisite shop cost requirements * The Messenger: don't double the cost anymore * The Messenger: remove centered mind prereq instead of checking for it * The Messenger: use cost as a property to cache it and gain back speed * The Messenger: hardcode the prereqs for more speed * make the linter and mypy happier * use cached_property --- worlds/messenger/Shop.py | 61 +++++++++++++------------- worlds/messenger/SubClasses.py | 41 ++++++++++++----- worlds/messenger/__init__.py | 11 ++--- worlds/messenger/test/TestShop.py | 8 ++-- worlds/messenger/test/TestShopChest.py | 13 +++--- 5 files changed, 78 insertions(+), 56 deletions(-) diff --git a/worlds/messenger/Shop.py b/worlds/messenger/Shop.py index 68f41534..f0915f5d 100644 --- a/worlds/messenger/Shop.py +++ b/worlds/messenger/Shop.py @@ -1,5 +1,4 @@ -from random import Random -from typing import Dict, TYPE_CHECKING, NamedTuple, Tuple, List +from typing import Dict, List, NamedTuple, Optional, Set, TYPE_CHECKING, Tuple, Union if TYPE_CHECKING: from . import MessengerWorld @@ -30,45 +29,47 @@ class ShopData(NamedTuple): internal_name: str min_price: int max_price: int - default_price: int = 0 + prerequisite: Optional[Union[str, Set[str]]] = None SHOP_ITEMS: Dict[str, ShopData] = { "Karuta Plates": ShopData("HP_UPGRADE_1", 20, 200), - "Serendipitous Bodies": ShopData("ENEMY_DROP_HP", 20, 300), - "Path of Resilience": ShopData("DAMAGE_REDUCTION", 100, 500), - "Kusari Jacket": ShopData("HP_UPGRADE_2", 100, 500), + "Serendipitous Bodies": ShopData("ENEMY_DROP_HP", 20, 300, "The Shop - Karuta Plates"), + "Path of Resilience": ShopData("DAMAGE_REDUCTION", 100, 500, "The Shop - Serendipitous Bodies"), + "Kusari Jacket": ShopData("HP_UPGRADE_2", 100, 500, "The Shop - Serendipitous Bodies"), "Energy Shuriken": ShopData("SHURIKEN", 20, 200), - "Serendipitous Minds": ShopData("ENEMY_DROP_MANA", 20, 300), - "Prepared Mind": ShopData("SHURIKEN_UPGRADE_1", 100, 600), - "Meditation": ShopData("CHECKPOINT_FULL", 100, 600), - "Rejuvenative Spirit": ShopData("POTION_FULL_HEAL_AND_HP", 300, 800), - "Centered Mind": ShopData("SHURIKEN_UPGRADE_2", 300, 800), + "Serendipitous Minds": ShopData("ENEMY_DROP_MANA", 20, 300, "The Shop - Energy Shuriken"), + "Prepared Mind": ShopData("SHURIKEN_UPGRADE_1", 100, 600, "The Shop - Serendipitous Minds"), + "Meditation": ShopData("CHECKPOINT_FULL", 100, 600, + {"The Shop - Prepared Mind", "The Shop - Kusari Jacket"}), + "Rejuvenative Spirit": ShopData("POTION_FULL_HEAL_AND_HP", 300, 800, "The Shop - Meditation"), + "Centered Mind": ShopData("SHURIKEN_UPGRADE_2", 300, 800, "The Shop - Meditation"), "Strike of the Ninja": ShopData("ATTACK_PROJECTILE", 20, 200), - "Second Wind": ShopData("AIR_RECOVER", 20, 350), - "Currents Master": ShopData("SWIM_DASH", 100, 600), - "Aerobatics Warrior": ShopData("GLIDE_ATTACK", 300, 800), - "Demon's Bane": ShopData("CHARGED_ATTACK", 400, 1000), + "Second Wind": ShopData("AIR_RECOVER", 20, 350, "The Shop - Strike of the Ninja"), + "Currents Master": ShopData("SWIM_DASH", 100, 600, "The Shop - Second Wind"), + "Aerobatics Warrior": ShopData("GLIDE_ATTACK", 300, 800, "The Shop - Currents Master"), + "Demon's Bane": ShopData("CHARGED_ATTACK", 400, 1000, + {"The Shop - Rejuvenative Spirit", "The Shop - Aerobatics Warrior"}), "Devil's Due": ShopData("QUARBLE_DISCOUNT_50", 20, 200), "Time Sense": ShopData("TIME_WARP", 20, 300), - "Power Sense": ShopData("POWER_SEAL", 100, 800), - "Focused Power Sense": ShopData("POWER_SEAL_WORLD_MAP", 300, 600), + "Power Sense": ShopData("POWER_SEAL", 100, 800, "The Shop - Time Sense"), + "Focused Power Sense": ShopData("POWER_SEAL_WORLD_MAP", 300, 600, "The Shop - Power Sense"), } FIGURINES: Dict[str, ShopData] = { - "Green Kappa Figurine": ShopData("GREEN_KAPPA", 100, 500, 450), - "Blue Kappa Figurine": ShopData("BLUE_KAPPA", 100, 500, 450), - "Ountarde Figurine": ShopData("OUNTARDE", 100, 500, 450), - "Red Kappa Figurine": ShopData("RED_KAPPA", 100, 500, 450), - "Demon King Figurine": ShopData("DEMON_KING", 600, 2000, 2000), - "Quillshroom Figurine": ShopData("QUILLSHROOM", 100, 500, 450), - "Jumping Quillshroom Figurine": ShopData("JUMPING_QUILLSHROOM", 100, 500, 450), - "Scurubu Figurine": ShopData("SCURUBU", 100, 500, 450), - "Jumping Scurubu Figurine": ShopData("JUMPING_SCURUBU", 100, 500, 450), - "Wallaxer Figurine": ShopData("WALLAXER", 100, 500, 450), - "Barmath'azel Figurine": ShopData("BARMATHAZEL", 600, 2000, 2000), - "Queen of Quills Figurine": ShopData("QUEEN_OF_QUILLS", 400, 1000, 2000), - "Demon Hive Figurine": ShopData("DEMON_HIVE", 100, 500, 450), + "Green Kappa Figurine": ShopData("GREEN_KAPPA", 100, 500), + "Blue Kappa Figurine": ShopData("BLUE_KAPPA", 100, 500), + "Ountarde Figurine": ShopData("OUNTARDE", 100, 500), + "Red Kappa Figurine": ShopData("RED_KAPPA", 100, 500), + "Demon King Figurine": ShopData("DEMON_KING", 600, 2000), + "Quillshroom Figurine": ShopData("QUILLSHROOM", 100, 500), + "Jumping Quillshroom Figurine": ShopData("JUMPING_QUILLSHROOM", 100, 500), + "Scurubu Figurine": ShopData("SCURUBU", 100, 500), + "Jumping Scurubu Figurine": ShopData("JUMPING_SCURUBU", 100, 500), + "Wallaxer Figurine": ShopData("WALLAXER", 100, 500), + "Barmath'azel Figurine": ShopData("BARMATHAZEL", 600, 2000), + "Queen of Quills Figurine": ShopData("QUEEN_OF_QUILLS", 400, 1000), + "Demon Hive Figurine": ShopData("DEMON_HIVE", 100, 500), } diff --git a/worlds/messenger/SubClasses.py b/worlds/messenger/SubClasses.py index bdab02e5..717b3898 100644 --- a/worlds/messenger/SubClasses.py +++ b/worlds/messenger/SubClasses.py @@ -1,10 +1,11 @@ -from typing import TYPE_CHECKING, Optional +from functools import cached_property +from typing import Optional, TYPE_CHECKING, cast -from BaseClasses import Region, Location, Item, ItemClassification, CollectionState -from .Constants import NOTES, PROG_ITEMS, PHOBEKINS, USEFUL_ITEMS +from BaseClasses import CollectionState, Item, ItemClassification, Location, Region +from .Constants import NOTES, PHOBEKINS, PROG_ITEMS, USEFUL_ITEMS from .Options import Goal -from .Regions import REGIONS, SEALS, MEGA_SHARDS -from .Shop import SHOP_ITEMS, PROG_SHOP_ITEMS, USEFUL_SHOP_ITEMS, FIGURINES +from .Regions import MEGA_SHARDS, REGIONS, SEALS +from .Shop import FIGURINES, PROG_SHOP_ITEMS, SHOP_ITEMS, USEFUL_SHOP_ITEMS if TYPE_CHECKING: from . import MessengerWorld @@ -29,7 +30,8 @@ class MessengerRegion(Region): locations += [seal_loc for seal_loc in SEALS[self.name]] if self.multiworld.shuffle_shards[self.player] and self.name in MEGA_SHARDS: locations += [shard for shard in MEGA_SHARDS[self.name]] - loc_dict = {loc: world.location_name_to_id[loc] if loc in world.location_name_to_id else None for loc in locations} + loc_dict = {loc: world.location_name_to_id[loc] if loc in world.location_name_to_id else None + for loc in locations} self.add_locations(loc_dict, MessengerLocation) world.multiworld.regions.append(self) @@ -44,19 +46,35 @@ class MessengerLocation(Location): class MessengerShopLocation(MessengerLocation): + @cached_property def cost(self) -> int: name = self.name.replace("The Shop - ", "") # TODO use `remove_prefix` when 3.8 finally gets dropped world: MessengerWorld = self.parent_region.multiworld.worlds[self.player] - return world.shop_prices.get(name, world.figurine_prices.get(name)) + # short circuit figurines which all require demon's bane be purchased, but nothing else + if "Figurine" in name: + return world.figurine_prices[name] +\ + cast(MessengerShopLocation, world.multiworld.get_location("The Shop - Demon's Bane", self.player)).cost + shop_data = SHOP_ITEMS[name] + if shop_data.prerequisite: + prereq_cost = 0 + if isinstance(shop_data.prerequisite, set): + for prereq in shop_data.prerequisite: + prereq_cost +=\ + cast(MessengerShopLocation, + world.multiworld.get_location(prereq, self.player)).cost + else: + prereq_cost +=\ + cast(MessengerShopLocation, + world.multiworld.get_location(shop_data.prerequisite, self.player)).cost + return world.shop_prices[name] + prereq_cost + return world.shop_prices[name] def can_afford(self, state: CollectionState) -> bool: world: MessengerWorld = state.multiworld.worlds[self.player] - cost = self.cost() * 2 - if cost >= 1000: - cost *= 2 + cost = self.cost can_afford = state.has("Shards", self.player, min(cost, world.total_shards)) if "Figurine" in self.name: - return state.has("Money Wrench", self.player) and can_afford\ + can_afford = state.has("Money Wrench", self.player) and can_afford\ and state.can_reach("Money Wrench", "Location", self.player) return can_afford @@ -75,4 +93,3 @@ class MessengerItem(Item): else: item_class = ItemClassification.filler super().__init__(name, item_class, item_id, player) - diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index e15eb3a3..f3644487 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -81,6 +81,8 @@ class MessengerWorld(World): self.multiworld.shuffle_seals[self.player].value = PowerSeals.option_true self.total_seals = self.multiworld.total_seals[self.player].value + self.shop_prices, self.figurine_prices = shuffle_shop_prices(self) + def create_regions(self) -> None: for region in [MessengerRegion(reg_name, self) for reg_name in REGIONS]: if region.name in REGION_CONNECTIONS: @@ -93,7 +95,7 @@ class MessengerWorld(World): for item in self.item_name_to_id if item not in { - "Power Seal", *NOTES, + "Power Seal", *NOTES, *FIGURINES, *{collected_item.name for collected_item in self.multiworld.precollected_items[self.player]}, } and "Time Shard" not in item ] @@ -119,13 +121,14 @@ class MessengerWorld(World): logging.warning(f"Not enough locations for total seals setting " f"({self.multiworld.total_seals[self.player].value}). Adjusting to {total_seals}") self.total_seals = total_seals - self.required_seals = int(self.multiworld.percent_seals_required[self.player].value / 100 * self.total_seals) + self.required_seals =\ + int(self.multiworld.percent_seals_required[self.player].value / 100 * self.total_seals) seals = [self.create_item("Power Seal") for _ in range(self.total_seals)] for i in range(self.required_seals): seals[i].classification = ItemClassification.progression_skip_balancing itempool += seals - + remaining_fill = len(self.multiworld.get_unfilled_locations(self.player)) - len(itempool) filler_pool = dict(list(FILLER.items())[2:]) if remaining_fill < 10 else FILLER itempool += [self.create_item(filler_item) @@ -139,8 +142,6 @@ class MessengerWorld(World): self.multiworld.itempool += itempool def set_rules(self) -> None: - self.shop_prices, self.figurine_prices = shuffle_shop_prices(self) - logic = self.multiworld.logic_level[self.player] if logic == Logic.option_normal: Rules.MessengerRules(self).set_messenger_rules() diff --git a/worlds/messenger/test/TestShop.py b/worlds/messenger/test/TestShop.py index dcc750f6..89ea9362 100644 --- a/worlds/messenger/test/TestShop.py +++ b/worlds/messenger/test/TestShop.py @@ -20,7 +20,7 @@ class ShopCostTest(MessengerTestBase): prices: Dict[str, int] = self.multiworld.worlds[self.player].shop_prices for loc, price in prices.items(): with self.subTest("prices", loc=loc): - self.assertEqual(price, self.multiworld.get_location(f"The Shop - {loc}", self.player).cost()) + self.assertLessEqual(price, self.multiworld.get_location(f"The Shop - {loc}", self.player).cost) self.assertTrue(loc in SHOP_ITEMS) self.assertEqual(len(prices), len(SHOP_ITEMS)) @@ -49,7 +49,7 @@ class ShopCostMinTest(ShopCostTest): "shop_price": "random", "shuffle_seals": "false", } - + def testShopRules(self) -> None: if self.multiworld.worlds[self.player].total_shards: super().testShopRules() @@ -94,7 +94,7 @@ class PlandoTest(MessengerTestBase): self.assertIn(price, self.options["shop_price_plan"]["Serendipitous Bodies"]) loc = f"The Shop - {loc}" - self.assertEqual(price, self.multiworld.get_location(loc, self.player).cost()) + self.assertLessEqual(price, self.multiworld.get_location(loc, self.player).cost) self.assertTrue(loc.replace("The Shop - ", "") in SHOP_ITEMS) self.assertEqual(len(prices), len(SHOP_ITEMS)) @@ -106,6 +106,6 @@ class PlandoTest(MessengerTestBase): elif loc == "Demon Hive Figurine": self.assertIn(price, self.options["shop_price_plan"]["Demon Hive Figurine"]) - self.assertEqual(price, self.multiworld.get_location(loc, self.player).cost()) + self.assertLessEqual(price, self.multiworld.get_location(loc, self.player).cost) self.assertTrue(loc in FIGURINES) self.assertEqual(len(figures), len(FIGURINES)) diff --git a/worlds/messenger/test/TestShopChest.py b/worlds/messenger/test/TestShopChest.py index 273c3ea7..ad4178fb 100644 --- a/worlds/messenger/test/TestShopChest.py +++ b/worlds/messenger/test/TestShopChest.py @@ -44,7 +44,8 @@ class HalfSealsRequired(MessengerTestBase): self.assertEqual(self.multiworld.worlds[self.player].total_seals, 45) self.assertEqual(self.multiworld.worlds[self.player].required_seals, 22) total_seals = [seal for seal in self.multiworld.itempool if seal.name == "Power Seal"] - required_seals = [seal for seal in total_seals if seal.classification == ItemClassification.progression_skip_balancing] + required_seals = [seal for seal in total_seals + if seal.classification == ItemClassification.progression_skip_balancing] self.assertEqual(len(total_seals), 45) self.assertEqual(len(required_seals), 22) @@ -62,7 +63,8 @@ class ThirtyThirtySeals(MessengerTestBase): self.assertEqual(self.multiworld.worlds[self.player].total_seals, 30) self.assertEqual(self.multiworld.worlds[self.player].required_seals, 10) total_seals = [seal for seal in self.multiworld.itempool if seal.name == "Power Seal"] - required_seals = [seal for seal in total_seals if seal.classification == ItemClassification.progression_skip_balancing] + required_seals = [seal for seal in total_seals + if seal.classification == ItemClassification.progression_skip_balancing] self.assertEqual(len(total_seals), 30) self.assertEqual(len(required_seals), 10) @@ -74,9 +76,9 @@ class MaxSealsNoShards(MessengerTestBase): } def testSealsAmount(self) -> None: - """Should set total seals to 57 since shards aren't shuffled.""" + """Should set total seals to 70 since shards aren't shuffled.""" self.assertEqual(self.multiworld.total_seals[self.player], 85) - self.assertEqual(self.multiworld.worlds[self.player].total_seals, 57) + self.assertEqual(self.multiworld.worlds[self.player].total_seals, 70) class MaxSealsWithShards(MessengerTestBase): @@ -92,6 +94,7 @@ class MaxSealsWithShards(MessengerTestBase): self.assertEqual(self.multiworld.worlds[self.player].total_seals, 85) self.assertEqual(self.multiworld.worlds[self.player].required_seals, 85) total_seals = [seal for seal in self.multiworld.itempool if seal.name == "Power Seal"] - required_seals = [seal for seal in total_seals if seal.classification == ItemClassification.progression_skip_balancing] + required_seals = [seal for seal in total_seals + if seal.classification == ItemClassification.progression_skip_balancing] self.assertEqual(len(total_seals), 85) self.assertEqual(len(required_seals), 85)