diff --git a/worlds/tunic/__init__.py b/worlds/tunic/__init__.py index e86f7313..2ee58d42 100644 --- a/worlds/tunic/__init__.py +++ b/worlds/tunic/__init__.py @@ -11,7 +11,8 @@ from .er_scripts import create_er_regions from .grass import grass_location_table, grass_location_name_to_id, grass_location_name_groups, excluded_grass_locations from .er_data import portal_mapping, RegionInfo, tunic_er_regions from .options import (TunicOptions, EntranceRando, tunic_option_groups, tunic_option_presets, TunicPlandoConnections, - LaurelsLocation, LogicRules, LaurelsZips, IceGrappling, LadderStorage) + LaurelsLocation, LogicRules, LaurelsZips, IceGrappling, LadderStorage, check_options, + get_hexagons_in_pool, HexagonQuestAbilityUnlockType) from .combat_logic import area_data, CombatState from worlds.AutoWorld import WebWorld, World from Options import PlandoConnection, OptionError @@ -109,6 +110,8 @@ class TunicWorld(World): ut_can_gen_without_yaml = True # class var that tells it to ignore the player yaml def generate_early(self) -> None: + check_options(self) + if self.options.logic_rules >= LogicRules.option_no_major_glitches: self.options.laurels_zips.value = LaurelsZips.option_true self.options.ice_grappling.value = IceGrappling.option_medium @@ -144,6 +147,7 @@ class TunicWorld(World): self.options.lanternless.value = self.passthrough["lanternless"] self.options.maskless.value = self.passthrough["maskless"] self.options.hexagon_quest.value = self.passthrough["hexagon_quest"] + self.options.hexagon_quest_ability_type.value = self.passthrough.get("hexagon_quest_ability_type", 0) self.options.entrance_rando.value = self.passthrough["entrance_rando"] self.options.shuffle_ladders.value = self.passthrough["shuffle_ladders"] self.options.grass_randomizer.value = self.passthrough.get("grass_randomizer", 0) @@ -261,6 +265,10 @@ class TunicWorld(World): items_to_create: Dict[str, int] = {item: data.quantity_in_item_pool for item, data in item_table.items()} + # Calculate number of hexagons in item pool + if self.options.hexagon_quest: + items_to_create[gold_hexagon] = get_hexagons_in_pool(self) + for money_fool in fool_tiers[self.options.fool_traps]: items_to_create["Fool Trap"] += items_to_create[money_fool] items_to_create[money_fool] = 0 @@ -291,11 +299,21 @@ class TunicWorld(World): items_to_create["Grass"] -= len(excluded_grass_locations) if self.options.keys_behind_bosses: - for rgb_hexagon, location in hexagon_locations.items(): - hex_item = self.create_item(gold_hexagon if self.options.hexagon_quest else rgb_hexagon) - self.get_location(location).place_locked_item(hex_item) - items_to_create[rgb_hexagon] = 0 - items_to_create[gold_hexagon] -= 3 + rgb_hexagons = list(hexagon_locations.keys()) + # shuffle these in case not all are placed in hex quest + self.random.shuffle(rgb_hexagons) + for rgb_hexagon in rgb_hexagons: + location = hexagon_locations[rgb_hexagon] + if self.options.hexagon_quest: + if items_to_create[gold_hexagon] > 0: + hex_item = self.create_item(gold_hexagon) + items_to_create[gold_hexagon] -= 1 + items_to_create[rgb_hexagon] = 0 + self.get_location(location).place_locked_item(hex_item) + else: + hex_item = self.create_item(rgb_hexagon) + self.get_location(location).place_locked_item(hex_item) + items_to_create[rgb_hexagon] = 0 # Filler items in the item pool available_filler: List[str] = [filler for filler in items_to_create if items_to_create[filler] > 0 and @@ -323,13 +341,11 @@ class TunicWorld(World): remove_filler(ladder_count) if self.options.hexagon_quest: - # Calculate number of hexagons in item pool - hexagon_goal = self.options.hexagon_goal - extra_hexagons = self.options.extra_hexagon_percentage - items_to_create[gold_hexagon] += int((Decimal(100 + extra_hexagons) / 100 * hexagon_goal).to_integral_value(rounding=ROUND_HALF_UP)) - # Replace pages and normal hexagons with filler for replaced_item in list(filter(lambda item: "Pages" in item or item in hexagon_locations, items_to_create)): + if replaced_item in item_name_groups["Abilities"] and self.options.ability_shuffling \ + and self.options.hexagon_quest_ability_type == "pages": + continue filler_name = self.get_filler_item_name() items_to_create[filler_name] += items_to_create[replaced_item] if items_to_create[filler_name] >= 1 and filler_name not in available_filler: @@ -441,7 +457,7 @@ class TunicWorld(World): def create_regions(self) -> None: self.tunic_portal_pairs = {} self.er_portal_hints = {} - self.ability_unlocks = randomize_ability_unlocks(self.random, self.options) + self.ability_unlocks = randomize_ability_unlocks(self) # stuff for universal tracker support, can be ignored for standard gen if self.using_ut: @@ -504,7 +520,8 @@ class TunicWorld(World): return change def write_spoiler_header(self, spoiler_handle: TextIO): - if self.options.hexagon_quest and self.options.ability_shuffling: + if self.options.hexagon_quest and self.options.ability_shuffling\ + and self.options.hexagon_quest_ability_type == HexagonQuestAbilityUnlockType.option_hexagons: spoiler_handle.write("\nAbility Unlocks (Hexagon Quest):\n") for ability in self.ability_unlocks: # Remove parentheses for better readability @@ -567,6 +584,7 @@ class TunicWorld(World): "sword_progression": self.options.sword_progression.value, "ability_shuffling": self.options.ability_shuffling.value, "hexagon_quest": self.options.hexagon_quest.value, + "hexagon_quest_ability_type": self.options.hexagon_quest_ability_type.value, "fool_traps": self.options.fool_traps.value, "laurels_zips": self.options.laurels_zips.value, "ice_grappling": self.options.ice_grappling.value, diff --git a/worlds/tunic/options.py b/worlds/tunic/options.py index d2ea8280..3ace28cf 100644 --- a/worlds/tunic/options.py +++ b/worlds/tunic/options.py @@ -1,8 +1,14 @@ +import logging from dataclasses import dataclass -from typing import Dict, Any +from typing import Dict, Any, TYPE_CHECKING + +from decimal import Decimal, ROUND_HALF_UP + from Options import (DefaultOnToggle, Toggle, StartInventoryPool, Choice, Range, TextChoice, PlandoConnections, PerGameCommonOptions, OptionGroup, Visibility, NamedRange) from .er_data import portal_mapping +if TYPE_CHECKING: + from . import TunicWorld class SwordProgression(DefaultOnToggle): @@ -24,6 +30,7 @@ class StartWithSword(Toggle): class KeysBehindBosses(Toggle): """ Places the three hexagon keys behind their respective boss fight in your world. + If playing Hexagon Quest, it will place three gold hexagons at the boss locations. """ internal_name = "keys_behind_bosses" display_name = "Keys Behind Bosses" @@ -32,7 +39,8 @@ class KeysBehindBosses(Toggle): class AbilityShuffling(DefaultOnToggle): """ Locks the usage of Prayer, Holy Cross*, and the Icebolt combo until the relevant pages of the manual have been found. - If playing Hexagon Quest, abilities are instead randomly unlocked after obtaining 25%, 50%, and 75% of the required Hexagon goal amount. + If playing Hexagon Quest, abilities are instead randomly unlocked after obtaining 25%, 50%, and 75% of the required + Hexagon goal amount, unless the option is set to have them unlock via pages instead. * Certain Holy Cross usages are still allowed, such as the free bomb codes, the seeking spell, and other player-facing codes. """ internal_name = "ability_shuffling" @@ -84,14 +92,16 @@ class HexagonGoal(Range): """ internal_name = "hexagon_goal" display_name = "Gold Hexagons Required" - range_start = 15 - range_end = 50 + range_start = 1 + range_end = 100 default = 20 class ExtraHexagonPercentage(Range): """ How many extra Gold Questagons are shuffled into the item pool, taken as a percentage of the goal amount. + The max number of Gold Questagons that can be in the item pool is 100, so this option may be overridden and/or + reduced if the Hexagon Goal amount is greater than 50. """ internal_name = "extra_hexagon_percentage" display_name = "Percentage of Extra Gold Hexagons" @@ -100,11 +110,27 @@ class ExtraHexagonPercentage(Range): default = 50 +class HexagonQuestAbilityUnlockType(Choice): + """ + Determines how abilities are unlocked when playing Hexagon Quest with Shuffled Abilities enabled. + + Hexagons: A new ability is randomly unlocked after obtaining 25%, 50%, and 75% of the required Hexagon goal amount. Requires at least 3 Gold Hexagons in the item pool, or 15 if Keys Behind Bosses is enabled. + Pages: Abilities are unlocked by finding specific pages in the manual. + + This option does nothing if Shuffled Abilities is not enabled. + """ + internal_name = "hexagon_quest_ability_type" + display_name = "Hexagon Quest Ability Unlocks" + option_hexagons = 0 + option_pages = 1 + default = 0 + + class EntranceRando(TextChoice): """ Randomize the connections between scenes. A small, very lost fox on a big adventure. - + If you set this option's value to a string, it will be used as a custom seed. Every player who uses the same custom seed will have the same entrances, choosing the most restrictive settings among these players for the purpose of pairing entrances. """ @@ -301,6 +327,7 @@ class TunicOptions(PerGameCommonOptions): hexagon_quest: HexagonQuest hexagon_goal: HexagonGoal extra_hexagon_percentage: ExtraHexagonPercentage + hexagon_quest_ability_type: HexagonQuestAbilityUnlockType shuffle_ladders: ShuffleLadders grass_randomizer: GrassRandomizer @@ -323,6 +350,12 @@ class TunicOptions(PerGameCommonOptions): tunic_option_groups = [ + OptionGroup("Hexagon Quest Options", [ + HexagonQuest, + HexagonGoal, + ExtraHexagonPercentage, + HexagonQuestAbilityUnlockType + ]), OptionGroup("Logic Options", [ CombatLogic, Lanternless, @@ -357,3 +390,23 @@ tunic_option_presets: Dict[str, Dict[str, Any]] = { "lanternless": True, }, } + + +def check_options(world: "TunicWorld"): + options = world.options + if options.hexagon_quest and options.ability_shuffling and options.hexagon_quest_ability_type == HexagonQuestAbilityUnlockType.option_hexagons: + total_hexes = get_hexagons_in_pool(world) + min_hexes = 3 + + if options.keys_behind_bosses: + min_hexes = 15 + if total_hexes < min_hexes: + logging.warning(f"TUNIC: Not enough Gold Hexagons in {world.player_name}'s item pool for Hexagon Ability Shuffle with the selected options. Ability Shuffle mode will be switched to Pages.") + options.hexagon_quest_ability_type.value = HexagonQuestAbilityUnlockType.option_pages + + +def get_hexagons_in_pool(world: "TunicWorld"): + # Calculate number of hexagons in item pool + options = world.options + return min(int((Decimal(100 + options.extra_hexagon_percentage) / 100 * options.hexagon_goal) + .to_integral_value(rounding=ROUND_HALF_UP)), 100) diff --git a/worlds/tunic/rules.py b/worlds/tunic/rules.py index b58ad730..c7b4ad0d 100644 --- a/worlds/tunic/rules.py +++ b/worlds/tunic/rules.py @@ -1,9 +1,9 @@ -from random import Random from typing import Dict, TYPE_CHECKING +from decimal import Decimal, ROUND_HALF_UP from worlds.generic.Rules import set_rule, forbid_item, add_rule from BaseClasses import CollectionState -from .options import TunicOptions, LadderStorage, IceGrappling +from .options import LadderStorage, IceGrappling, HexagonQuestAbilityUnlockType if TYPE_CHECKING: from . import TunicWorld @@ -34,14 +34,21 @@ bomb_walls = ["East Forest - Bombable Wall", "Eastern Vault Fortress - [East Win "Quarry - [West] Upper Area Bombable Wall", "Ruined Atoll - [Northwest] Bombable Wall"] -def randomize_ability_unlocks(random: Random, options: TunicOptions) -> Dict[str, int]: +def randomize_ability_unlocks(world: "TunicWorld") -> Dict[str, int]: + random = world.random + options = world.options + + abilities = [prayer, holy_cross, icebolt] ability_requirement = [1, 1, 1] - if options.hexagon_quest.value: + random.shuffle(abilities) + + if options.hexagon_quest.value and options.hexagon_quest_ability_type == HexagonQuestAbilityUnlockType.option_hexagons: hexagon_goal = options.hexagon_goal.value # Set ability unlocks to 25, 50, and 75% of goal amount ability_requirement = [hexagon_goal // 4, hexagon_goal // 2, hexagon_goal * 3 // 4] - abilities = [prayer, holy_cross, icebolt] - random.shuffle(abilities) + if any(req == 0 for req in ability_requirement): + ability_requirement = [1, 2, 3] + return dict(zip(abilities, ability_requirement)) @@ -50,7 +57,7 @@ def has_ability(ability: str, state: CollectionState, world: "TunicWorld") -> bo ability_unlocks = world.ability_unlocks if not options.ability_shuffling: return True - if options.hexagon_quest: + if options.hexagon_quest and options.hexagon_quest_ability_type == HexagonQuestAbilityUnlockType.option_hexagons: return state.has(gold_hexagon, world.player, ability_unlocks[ability]) return state.has(ability, world.player)