TUNIC: Expanded hexagon quest options (#4076)

* More hex quest updates

- Implement page ability shuffle for hex quest
- Fix keys behind bosses if hex goal is less than 3
- Added check to fix conflicting hex quest options
- Add option to slot data

* Change option comparison

* Change option checking and fix some stuff

- also keep prayer first on low hex counts

* Update option defaulting

* Update option checking

* Fix option assignment again

* Show player name in option warning

* Add new option to universal tracker stuff

* Update __init__.py

* Make helper method for getting total hexagons in itempool

* Update options.py

* Update option value passthrough

* Change ability shuffle to default on

* Check for hexagons option when writing spoiler
This commit is contained in:
Silent
2025-03-07 19:43:02 -05:00
committed by GitHub
parent 2f0b81e12c
commit bc61221ec6
3 changed files with 103 additions and 25 deletions

View File

@@ -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 .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 .er_data import portal_mapping, RegionInfo, tunic_er_regions
from .options import (TunicOptions, EntranceRando, tunic_option_groups, tunic_option_presets, TunicPlandoConnections, 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 .combat_logic import area_data, CombatState
from worlds.AutoWorld import WebWorld, World from worlds.AutoWorld import WebWorld, World
from Options import PlandoConnection, OptionError 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 ut_can_gen_without_yaml = True # class var that tells it to ignore the player yaml
def generate_early(self) -> None: def generate_early(self) -> None:
check_options(self)
if self.options.logic_rules >= LogicRules.option_no_major_glitches: if self.options.logic_rules >= LogicRules.option_no_major_glitches:
self.options.laurels_zips.value = LaurelsZips.option_true self.options.laurels_zips.value = LaurelsZips.option_true
self.options.ice_grappling.value = IceGrappling.option_medium self.options.ice_grappling.value = IceGrappling.option_medium
@@ -144,6 +147,7 @@ class TunicWorld(World):
self.options.lanternless.value = self.passthrough["lanternless"] self.options.lanternless.value = self.passthrough["lanternless"]
self.options.maskless.value = self.passthrough["maskless"] self.options.maskless.value = self.passthrough["maskless"]
self.options.hexagon_quest.value = self.passthrough["hexagon_quest"] 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.entrance_rando.value = self.passthrough["entrance_rando"]
self.options.shuffle_ladders.value = self.passthrough["shuffle_ladders"] self.options.shuffle_ladders.value = self.passthrough["shuffle_ladders"]
self.options.grass_randomizer.value = self.passthrough.get("grass_randomizer", 0) 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()} 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]: for money_fool in fool_tiers[self.options.fool_traps]:
items_to_create["Fool Trap"] += items_to_create[money_fool] items_to_create["Fool Trap"] += items_to_create[money_fool]
items_to_create[money_fool] = 0 items_to_create[money_fool] = 0
@@ -291,11 +299,21 @@ class TunicWorld(World):
items_to_create["Grass"] -= len(excluded_grass_locations) items_to_create["Grass"] -= len(excluded_grass_locations)
if self.options.keys_behind_bosses: if self.options.keys_behind_bosses:
for rgb_hexagon, location in hexagon_locations.items(): rgb_hexagons = list(hexagon_locations.keys())
hex_item = self.create_item(gold_hexagon if self.options.hexagon_quest else rgb_hexagon) # shuffle these in case not all are placed in hex quest
self.get_location(location).place_locked_item(hex_item) self.random.shuffle(rgb_hexagons)
items_to_create[rgb_hexagon] = 0 for rgb_hexagon in rgb_hexagons:
items_to_create[gold_hexagon] -= 3 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 # Filler items in the item pool
available_filler: List[str] = [filler for filler in items_to_create if items_to_create[filler] > 0 and 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) remove_filler(ladder_count)
if self.options.hexagon_quest: 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 # 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)): 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() filler_name = self.get_filler_item_name()
items_to_create[filler_name] += items_to_create[replaced_item] items_to_create[filler_name] += items_to_create[replaced_item]
if items_to_create[filler_name] >= 1 and filler_name not in available_filler: 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: def create_regions(self) -> None:
self.tunic_portal_pairs = {} self.tunic_portal_pairs = {}
self.er_portal_hints = {} 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 # stuff for universal tracker support, can be ignored for standard gen
if self.using_ut: if self.using_ut:
@@ -504,7 +520,8 @@ class TunicWorld(World):
return change return change
def write_spoiler_header(self, spoiler_handle: TextIO): 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") spoiler_handle.write("\nAbility Unlocks (Hexagon Quest):\n")
for ability in self.ability_unlocks: for ability in self.ability_unlocks:
# Remove parentheses for better readability # Remove parentheses for better readability
@@ -567,6 +584,7 @@ class TunicWorld(World):
"sword_progression": self.options.sword_progression.value, "sword_progression": self.options.sword_progression.value,
"ability_shuffling": self.options.ability_shuffling.value, "ability_shuffling": self.options.ability_shuffling.value,
"hexagon_quest": self.options.hexagon_quest.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, "fool_traps": self.options.fool_traps.value,
"laurels_zips": self.options.laurels_zips.value, "laurels_zips": self.options.laurels_zips.value,
"ice_grappling": self.options.ice_grappling.value, "ice_grappling": self.options.ice_grappling.value,

View File

@@ -1,8 +1,14 @@
import logging
from dataclasses import dataclass 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, from Options import (DefaultOnToggle, Toggle, StartInventoryPool, Choice, Range, TextChoice, PlandoConnections,
PerGameCommonOptions, OptionGroup, Visibility, NamedRange) PerGameCommonOptions, OptionGroup, Visibility, NamedRange)
from .er_data import portal_mapping from .er_data import portal_mapping
if TYPE_CHECKING:
from . import TunicWorld
class SwordProgression(DefaultOnToggle): class SwordProgression(DefaultOnToggle):
@@ -24,6 +30,7 @@ class StartWithSword(Toggle):
class KeysBehindBosses(Toggle): class KeysBehindBosses(Toggle):
""" """
Places the three hexagon keys behind their respective boss fight in your world. 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" internal_name = "keys_behind_bosses"
display_name = "Keys Behind Bosses" display_name = "Keys Behind Bosses"
@@ -32,7 +39,8 @@ class KeysBehindBosses(Toggle):
class AbilityShuffling(DefaultOnToggle): class AbilityShuffling(DefaultOnToggle):
""" """
Locks the usage of Prayer, Holy Cross*, and the Icebolt combo until the relevant pages of the manual have been found. 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. * 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" internal_name = "ability_shuffling"
@@ -84,14 +92,16 @@ class HexagonGoal(Range):
""" """
internal_name = "hexagon_goal" internal_name = "hexagon_goal"
display_name = "Gold Hexagons Required" display_name = "Gold Hexagons Required"
range_start = 15 range_start = 1
range_end = 50 range_end = 100
default = 20 default = 20
class ExtraHexagonPercentage(Range): class ExtraHexagonPercentage(Range):
""" """
How many extra Gold Questagons are shuffled into the item pool, taken as a percentage of the goal amount. 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" internal_name = "extra_hexagon_percentage"
display_name = "Percentage of Extra Gold Hexagons" display_name = "Percentage of Extra Gold Hexagons"
@@ -100,6 +110,22 @@ class ExtraHexagonPercentage(Range):
default = 50 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): class EntranceRando(TextChoice):
""" """
Randomize the connections between scenes. Randomize the connections between scenes.
@@ -301,6 +327,7 @@ class TunicOptions(PerGameCommonOptions):
hexagon_quest: HexagonQuest hexagon_quest: HexagonQuest
hexagon_goal: HexagonGoal hexagon_goal: HexagonGoal
extra_hexagon_percentage: ExtraHexagonPercentage extra_hexagon_percentage: ExtraHexagonPercentage
hexagon_quest_ability_type: HexagonQuestAbilityUnlockType
shuffle_ladders: ShuffleLadders shuffle_ladders: ShuffleLadders
grass_randomizer: GrassRandomizer grass_randomizer: GrassRandomizer
@@ -323,6 +350,12 @@ class TunicOptions(PerGameCommonOptions):
tunic_option_groups = [ tunic_option_groups = [
OptionGroup("Hexagon Quest Options", [
HexagonQuest,
HexagonGoal,
ExtraHexagonPercentage,
HexagonQuestAbilityUnlockType
]),
OptionGroup("Logic Options", [ OptionGroup("Logic Options", [
CombatLogic, CombatLogic,
Lanternless, Lanternless,
@@ -357,3 +390,23 @@ tunic_option_presets: Dict[str, Dict[str, Any]] = {
"lanternless": True, "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)

View File

@@ -1,9 +1,9 @@
from random import Random
from typing import Dict, TYPE_CHECKING 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 worlds.generic.Rules import set_rule, forbid_item, add_rule
from BaseClasses import CollectionState from BaseClasses import CollectionState
from .options import TunicOptions, LadderStorage, IceGrappling from .options import LadderStorage, IceGrappling, HexagonQuestAbilityUnlockType
if TYPE_CHECKING: if TYPE_CHECKING:
from . import TunicWorld 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"] "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] 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 hexagon_goal = options.hexagon_goal.value
# Set ability unlocks to 25, 50, and 75% of goal amount # Set ability unlocks to 25, 50, and 75% of goal amount
ability_requirement = [hexagon_goal // 4, hexagon_goal // 2, hexagon_goal * 3 // 4] ability_requirement = [hexagon_goal // 4, hexagon_goal // 2, hexagon_goal * 3 // 4]
abilities = [prayer, holy_cross, icebolt] if any(req == 0 for req in ability_requirement):
random.shuffle(abilities) ability_requirement = [1, 2, 3]
return dict(zip(abilities, ability_requirement)) 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 ability_unlocks = world.ability_unlocks
if not options.ability_shuffling: if not options.ability_shuffling:
return True 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(gold_hexagon, world.player, ability_unlocks[ability])
return state.has(ability, world.player) return state.has(ability, world.player)