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

## What is this fixing or adding? Adds Bombless Start option, along with proper bomb logic. This involves updating `can_kill_most_things` to include checking how many bombs can be held. Many places where the ability to kill enemies was assumed, now have logic. This fixes some possible existing logic issues, for example: Mini Moldorm cave checks currently are always in logic despite the fact that on expert enemy health it would require 12 bombs to kill each mini moldorm. Overhauls options, pulling them out of core and in particular making large changes to how the shop options work. Co-authored-by: espeon65536 <81029175+espeon65536@users.noreply.github.com> Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com> Co-authored-by: Bondo <38083232+BadmoonzZ@users.noreply.github.com> Co-authored-by: espeon65536 <espeon65536@gmail.com> Co-authored-by: Fabian Dill <Berserker66@users.noreply.github.com>
351 lines
15 KiB
Python
351 lines
15 KiB
Python
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Optional, Union, List, Tuple, Callable, Dict, TYPE_CHECKING
|
|
|
|
from Fill import FillError
|
|
from .Options import LTTPBosses as Bosses
|
|
from .StateHelpers import can_shoot_arrows, can_extend_magic, can_get_good_bee, has_sword, has_beam_sword, \
|
|
has_melee_weapon, has_fire_source, can_use_bombs
|
|
|
|
if TYPE_CHECKING:
|
|
from . import ALTTPWorld
|
|
|
|
|
|
class Boss:
|
|
def __init__(self, name: str, enemizer_name: str, defeat_rule: Callable, player: int):
|
|
self.name = name
|
|
self.enemizer_name = enemizer_name
|
|
self.defeat_rule = defeat_rule
|
|
self.player = player
|
|
|
|
def can_defeat(self, state) -> bool:
|
|
return self.defeat_rule(state, self.player)
|
|
|
|
def __repr__(self):
|
|
return f"Boss({self.name})"
|
|
|
|
|
|
def BossFactory(boss: str, player: int) -> Optional[Boss]:
|
|
if boss in boss_table:
|
|
enemizer_name, defeat_rule = boss_table[boss]
|
|
return Boss(boss, enemizer_name, defeat_rule, player)
|
|
raise Exception('Unknown Boss: %s', boss)
|
|
|
|
|
|
def ArmosKnightsDefeatRule(state, player: int) -> bool:
|
|
# Magic amounts are probably a bit overkill
|
|
return (
|
|
has_melee_weapon(state, player) or
|
|
can_shoot_arrows(state, player) or
|
|
(state.has('Cane of Somaria', player) and can_extend_magic(state, player, 10)) or
|
|
(state.has('Cane of Byrna', player) and can_extend_magic(state, player, 16)) or
|
|
(state.has('Ice Rod', player) and can_extend_magic(state, player, 32)) or
|
|
(state.has('Fire Rod', player) and can_extend_magic(state, player, 32)) or
|
|
state.has('Blue Boomerang', player) or
|
|
state.has('Red Boomerang', player))
|
|
|
|
|
|
def LanmolasDefeatRule(state, player: int) -> bool:
|
|
return (
|
|
has_melee_weapon(state, player) or
|
|
state.has('Fire Rod', player) or
|
|
state.has('Ice Rod', player) or
|
|
state.has('Cane of Somaria', player) or
|
|
state.has('Cane of Byrna', player) or
|
|
can_shoot_arrows(state, player))
|
|
|
|
|
|
def MoldormDefeatRule(state, player: int) -> bool:
|
|
return has_melee_weapon(state, player)
|
|
|
|
|
|
def HelmasaurKingDefeatRule(state, player: int) -> bool:
|
|
# TODO: technically possible with the hammer
|
|
return (can_use_bombs(state, player, 5) or state.has("Hammer", player)) and (has_sword(state, player)
|
|
or can_shoot_arrows(state, player))
|
|
|
|
|
|
def ArrghusDefeatRule(state, player: int) -> bool:
|
|
if not state.has('Hookshot', player):
|
|
return False
|
|
# TODO: ideally we would have a check for bow and silvers, which combined with the
|
|
# hookshot is enough. This is not coded yet because the silvers that only work in pyramid feature
|
|
# makes this complicated
|
|
if has_melee_weapon(state, player):
|
|
return True
|
|
|
|
return ((state.has('Fire Rod', player) and (can_shoot_arrows(state, player) or can_extend_magic(state, player,
|
|
12))) or # assuming mostly gitting two puff with one shot
|
|
(state.has('Ice Rod', player) and (can_shoot_arrows(state, player) or can_extend_magic(state, player, 16))))
|
|
|
|
|
|
def MothulaDefeatRule(state, player: int) -> bool:
|
|
return (
|
|
has_melee_weapon(state, player) or
|
|
(state.has('Fire Rod', player) and can_extend_magic(state, player, 10)) or
|
|
# TODO: Not sure how much (if any) extend magic is needed for these two, since they only apply
|
|
# to non-vanilla locations, so are harder to test, so sticking with what VT has for now:
|
|
(state.has('Cane of Somaria', player) and can_extend_magic(state, player, 16)) or
|
|
(state.has('Cane of Byrna', player) and can_extend_magic(state, player, 16)) or
|
|
can_get_good_bee(state, player)
|
|
)
|
|
|
|
|
|
def BlindDefeatRule(state, player: int) -> bool:
|
|
return has_melee_weapon(state, player) or state.has('Cane of Somaria', player) or state.has('Cane of Byrna', player)
|
|
|
|
|
|
def KholdstareDefeatRule(state, player: int) -> bool:
|
|
return (
|
|
(
|
|
state.has('Fire Rod', player) or
|
|
(
|
|
state.has('Bombos', player) and
|
|
(has_sword(state, player) or state.multiworld.swordless[player])
|
|
)
|
|
) and
|
|
(
|
|
has_melee_weapon(state, player) or
|
|
(state.has('Fire Rod', player) and can_extend_magic(state, player, 20)) or
|
|
(
|
|
state.has('Fire Rod', player) and
|
|
state.has('Bombos', player) and
|
|
state.multiworld.swordless[player] and
|
|
can_extend_magic(state, player, 16)
|
|
)
|
|
)
|
|
)
|
|
|
|
|
|
def VitreousDefeatRule(state, player: int) -> bool:
|
|
return can_shoot_arrows(state, player) or has_melee_weapon(state, player)
|
|
|
|
|
|
def TrinexxDefeatRule(state, player: int) -> bool:
|
|
if not (state.has('Fire Rod', player) and state.has('Ice Rod', player)):
|
|
return False
|
|
return state.has('Hammer', player) or state.has('Tempered Sword', player) or state.has('Golden Sword', player) or \
|
|
(state.has('Master Sword', player) and can_extend_magic(state, player, 16)) or \
|
|
(has_sword(state, player) and can_extend_magic(state, player, 32))
|
|
|
|
|
|
def AgahnimDefeatRule(state, player: int) -> bool:
|
|
return has_sword(state, player) or state.has('Hammer', player) or state.has('Bug Catching Net', player)
|
|
|
|
|
|
def GanonDefeatRule(state, player: int) -> bool:
|
|
if state.multiworld.swordless[player]:
|
|
return state.has('Hammer', player) and \
|
|
has_fire_source(state, player) and \
|
|
state.has('Silver Bow', player) and \
|
|
can_shoot_arrows(state, player)
|
|
|
|
can_hurt = has_beam_sword(state, player)
|
|
common = can_hurt and has_fire_source(state, player)
|
|
# silverless ganon may be needed in anything higher than no glitches
|
|
if state.multiworld.glitches_required[player] != 'no_glitches':
|
|
# need to light torch a sufficient amount of times
|
|
return common and (state.has('Tempered Sword', player) or state.has('Golden Sword', player) or (
|
|
state.has('Silver Bow', player) and can_shoot_arrows(state, player)) or
|
|
state.has('Lamp', player) or can_extend_magic(state, player, 12))
|
|
|
|
else:
|
|
return common and state.has('Silver Bow', player) and can_shoot_arrows(state, player)
|
|
|
|
|
|
boss_table: Dict[str, Tuple[str, Optional[Callable]]] = {
|
|
'Armos Knights': ('Armos', ArmosKnightsDefeatRule),
|
|
'Lanmolas': ('Lanmola', LanmolasDefeatRule),
|
|
'Moldorm': ('Moldorm', MoldormDefeatRule),
|
|
'Helmasaur King': ('Helmasaur', HelmasaurKingDefeatRule),
|
|
'Arrghus': ('Arrghus', ArrghusDefeatRule),
|
|
'Mothula': ('Mothula', MothulaDefeatRule),
|
|
'Blind': ('Blind', BlindDefeatRule),
|
|
'Kholdstare': ('Kholdstare', KholdstareDefeatRule),
|
|
'Vitreous': ('Vitreous', VitreousDefeatRule),
|
|
'Trinexx': ('Trinexx', TrinexxDefeatRule),
|
|
'Agahnim': ('Agahnim', AgahnimDefeatRule),
|
|
'Agahnim2': ('Agahnim2', AgahnimDefeatRule)
|
|
}
|
|
|
|
boss_location_table: List[Tuple[str, str]] = [
|
|
('Ganons Tower', 'top'),
|
|
('Tower of Hera', None),
|
|
('Skull Woods', None),
|
|
('Ganons Tower', 'middle'),
|
|
('Eastern Palace', None),
|
|
('Desert Palace', None),
|
|
('Palace of Darkness', None),
|
|
('Swamp Palace', None),
|
|
('Thieves Town', None),
|
|
('Ice Palace', None),
|
|
('Misery Mire', None),
|
|
('Turtle Rock', None),
|
|
('Ganons Tower', 'bottom'),
|
|
]
|
|
|
|
|
|
def place_plando_bosses(world: "ALTTPWorld", bosses: List[str]) -> Tuple[List[str], List[Tuple[str, str]]]:
|
|
# Most to least restrictive order
|
|
boss_locations = boss_location_table.copy()
|
|
world.multiworld.random.shuffle(boss_locations)
|
|
boss_locations.sort(key=lambda location: -int(restrictive_boss_locations[location]))
|
|
already_placed_bosses: List[str] = []
|
|
|
|
for boss in bosses:
|
|
if "-" in boss: # handle plando locations
|
|
loc, boss = boss.split("-")
|
|
boss = boss.title()
|
|
level: str = None
|
|
if loc.split(" ")[-1] in {"top", "middle", "bottom"}:
|
|
# split off level
|
|
loc = loc.split(" ")
|
|
level = loc[-1]
|
|
loc = " ".join(loc[:-1])
|
|
loc = loc.title().replace("Of", "of")
|
|
place_boss(world, boss, loc, level)
|
|
already_placed_bosses.append(boss)
|
|
boss_locations.remove((loc, level))
|
|
else: # boss chosen with no specified locations
|
|
boss = boss.title()
|
|
boss_locations, already_placed_bosses = place_where_possible(world, boss, boss_locations)
|
|
|
|
return already_placed_bosses, boss_locations
|
|
|
|
|
|
def can_place_boss(boss: str, dungeon_name: str, level: Optional[str] = None) -> bool:
|
|
# blacklist approach
|
|
if boss in {"Agahnim", "Agahnim2", "Ganon"}:
|
|
return False
|
|
|
|
if dungeon_name == 'Ganons Tower':
|
|
if level == 'top':
|
|
if boss in {"Armos Knights", "Arrghus", "Blind", "Trinexx", "Lanmolas"}:
|
|
return False
|
|
elif level == 'middle':
|
|
if boss == "Blind":
|
|
return False
|
|
|
|
elif dungeon_name == 'Tower of Hera':
|
|
if boss in {"Armos Knights", "Arrghus", "Blind", "Trinexx", "Lanmolas"}:
|
|
return False
|
|
|
|
elif dungeon_name == 'Skull Woods':
|
|
if boss == "Trinexx":
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
restrictive_boss_locations: Dict[Tuple[str, str], bool] = {}
|
|
for location in boss_location_table:
|
|
restrictive_boss_locations[location] = not all(can_place_boss(boss, *location)
|
|
for boss in boss_table if not boss.startswith("Agahnim"))
|
|
|
|
|
|
def place_boss(world: "ALTTPWorld", boss: str, location: str, level: Optional[str]) -> None:
|
|
player = world.player
|
|
if location == 'Ganons Tower' and world.multiworld.mode[player] == 'inverted':
|
|
location = 'Inverted Ganons Tower'
|
|
logging.debug('Placing boss %s at %s', boss, location + (' (' + level + ')' if level else ''))
|
|
world.dungeons[location].bosses[level] = BossFactory(boss, player)
|
|
|
|
|
|
def format_boss_location(location_name: str, level: str) -> str:
|
|
return location_name + (' (' + level + ')' if level else '')
|
|
|
|
|
|
def place_bosses(world: "ALTTPWorld") -> None:
|
|
multiworld = world.multiworld
|
|
player = world.player
|
|
# will either be an int or a lower case string with ';' between options
|
|
boss_shuffle: Union[str, int] = multiworld.boss_shuffle[player].value
|
|
already_placed_bosses: List[str] = []
|
|
remaining_locations: List[Tuple[str, str]] = []
|
|
# handle plando
|
|
if isinstance(boss_shuffle, str):
|
|
# figure out our remaining mode, convert it to an int and remove it from plando_args
|
|
options = boss_shuffle.split(";")
|
|
boss_shuffle = Bosses.options[options.pop()]
|
|
# place our plando bosses
|
|
already_placed_bosses, remaining_locations = place_plando_bosses(world, options)
|
|
if boss_shuffle == Bosses.option_none: # vanilla boss locations
|
|
return
|
|
|
|
# Most to least restrictive order
|
|
if not remaining_locations and not already_placed_bosses:
|
|
remaining_locations = boss_location_table.copy()
|
|
multiworld.random.shuffle(remaining_locations)
|
|
remaining_locations.sort(key=lambda location: -int(restrictive_boss_locations[location]))
|
|
|
|
all_bosses = sorted(boss_table.keys()) # sorted to be deterministic on older pythons
|
|
placeable_bosses = [boss for boss in all_bosses if boss not in ['Agahnim', 'Agahnim2', 'Ganon']]
|
|
|
|
if boss_shuffle == Bosses.option_basic or boss_shuffle == Bosses.option_full:
|
|
if boss_shuffle == Bosses.option_basic: # vanilla bosses shuffled
|
|
bosses = placeable_bosses + ['Armos Knights', 'Lanmolas', 'Moldorm']
|
|
else: # all bosses present, the three duplicates chosen at random
|
|
bosses = placeable_bosses + multiworld.random.sample(placeable_bosses, 3)
|
|
|
|
# there is probably a better way to do this
|
|
while already_placed_bosses:
|
|
# remove already manually placed bosses, to prevent for example triple Lanmolas
|
|
boss = already_placed_bosses.pop()
|
|
if boss in bosses:
|
|
bosses.remove(boss)
|
|
# there may be more bosses than locations at this point, depending on manual placement
|
|
|
|
logging.debug('Bosses chosen %s', bosses)
|
|
|
|
multiworld.random.shuffle(bosses)
|
|
for loc, level in remaining_locations:
|
|
for _ in range(len(bosses)):
|
|
boss = bosses.pop()
|
|
if can_place_boss(boss, loc, level):
|
|
break
|
|
# put the boss back in queue
|
|
bosses.insert(0, boss) # this would be faster with deque,
|
|
# but the deque size is small enough that it should not matter
|
|
|
|
else:
|
|
raise FillError(f'Could not place boss for location {format_boss_location(loc, level)}')
|
|
|
|
place_boss(world, boss, loc, level)
|
|
|
|
elif boss_shuffle == Bosses.option_chaos: # all bosses chosen at random
|
|
for loc, level in remaining_locations:
|
|
try:
|
|
boss = multiworld.random.choice(
|
|
[b for b in placeable_bosses if can_place_boss(b, loc, level)])
|
|
except IndexError:
|
|
raise FillError(f'Could not place boss for location {format_boss_location(loc, level)}')
|
|
else:
|
|
place_boss(world, boss, loc, level)
|
|
|
|
elif boss_shuffle == Bosses.option_singularity:
|
|
primary_boss = multiworld.random.choice(placeable_bosses)
|
|
remaining_boss_locations, _ = place_where_possible(world, primary_boss, remaining_locations)
|
|
if remaining_boss_locations:
|
|
# pick a boss to go into the remaining locations
|
|
remaining_boss = multiworld.random.choice([boss for boss in placeable_bosses if all(
|
|
can_place_boss(boss, loc, level) for loc, level in remaining_boss_locations)])
|
|
remaining_boss_locations, _ = place_where_possible(world, remaining_boss, remaining_boss_locations)
|
|
if remaining_boss_locations:
|
|
raise Exception("Unfilled boss locations!")
|
|
else:
|
|
raise FillError(f"Could not find boss shuffle mode {boss_shuffle}")
|
|
|
|
|
|
def place_where_possible(world: "ALTTPWorld", boss: str, boss_locations) -> Tuple[List[Tuple[str, str]], List[str]]:
|
|
remainder: List[Tuple[str, str]] = []
|
|
placed_bosses: List[str] = []
|
|
for loc, level in boss_locations:
|
|
# place that boss where it can go
|
|
if can_place_boss(boss, loc, level):
|
|
place_boss(world, boss, loc, level)
|
|
placed_bosses.append(boss)
|
|
else:
|
|
remainder.append((loc, level))
|
|
return remainder, placed_bosses
|