Files
alttpr-python/source/enemizer/EnemyLogic.py
aerinon 915e7dc416 Ability to enemize fairies
Fix for blue square in caves
Lower limit for sprites on tiles that support it, to help with pot lifting
Swamp waterway enemies fixed to not drop
2023-09-26 08:59:31 -06:00

526 lines
23 KiB
Python

import math
from collections import defaultdict
import RaceRandom as random
from source.logic.Rule import RuleFactory
from source.dungeon.EnemyList import EnemySprite
# these are for drops only
def defeat_rule_single(world, player, enemy_sprite, region):
if enemy_sprite.kind == EnemySprite.Terrorpin:
# must be flipped
return has('Hammer', player)
elif enemy_sprite.kind == EnemySprite.RedBari:
# must be burned to drop
return or_rule(has('Fire Rod', player), and_rule(has_sword(player), has('Bombos', player)))
vln = enemy_vulnerability(world, player, enemy_sprite, region)
rules = []
if vln['Blunt'] != 0:
rules.append(has_blunt_weapon(player))
if vln['Stun'] != 0:
rules.append(buzzblob_rule(player))
if vln['Somaria'] != 0:
rules.append(somaria_rule(world, player, vln['Somaria']))
if vln['Byrna'] != 0:
rules.append(byrna_rule(world, player, vln['Byrna']))
if vln['Master'] != 0:
rules.append(has_class_2_weapon(player))
if vln['Bow'] != 0:
rules.append(bow_rule(world, player, vln['Bow']))
if vln['Silvers'] != 0:
rules.append(silvers_rule(world, player, vln['Silvers']))
if vln['Bomb'] != 0:
rules.append(bombs_rule(world, player, vln['Bomb']))
if vln['Hookshot'] != 0:
rules.append(has('Hookshot', player))
if vln['IceRod'] != 0:
rules.append(ice_rod_rule(world, player, vln['IceRod']))
if vln['FireRod'] != 0:
rules.append(fire_rod_rule(world, player, vln['FireRod']))
if vln['Boomerang'] != 0:
rules.append(has_boomerang(player))
if vln['Powder'] not in [0, -3]: # fairy doesn't make it drop
rules.append(magic_powder_rule(world, player, vln['Powder']))
# skip medallions if vln to Blunt?
if vln['Bombos'] != 0 and vln['Blunt'] == 0:
rules.append(medallion_rule(world, player, 'Bombos', vln['Bombos']))
if vln['Ether'] != 0 and vln['Blunt'] == 0:
rules.append(medallion_rule(world, player, 'Ether', vln['Ether']))
if vln['Quake'] != 0 and vln['Blunt'] == 0:
rules.append(medallion_rule(world, player, 'Quake', vln['Quake']))
if enemy_sprite.kind == EnemySprite.StalfosKnight:
# must be bombed once made vulnerable
return and_rule(can_use_bombs(world, player), or_rule(*rules))
return or_rule(*rules)
damage_cost = {
'Bomb': 1, 'Bow': 1, 'Silvers': 1,
'Powder': .5, 'Somaria': .5, 'Byrna': 1.125,
'FireRod': 1, 'IceRod': 1,
'Bombos': 2, 'Ether': 2, 'Quake': 2
}
# damage_set = ['Blunt', 'Stun', 'Master', 'Tempered', 'Boomerang', 'Hookshot', 'Bomb', 'Silvers', 'Bow',
# 'Somaria', 'Powder', 'FireRod', 'IceRod', 'Byrna', 'Bombos', 'Ether', 'Quake']
# these are for "challenge" rooms
def defeat_rule_multiple(world, player, enemy_sprite_region_pairs):
vln_list = {}
for sprite, region in enemy_sprite_region_pairs:
vln_list[(sprite, region)] = enemy_vulnerability(world, player, sprite, region)
# damage_accounting = {x: list(y) for x, y in damage_types.items()}
used_resources = {'Bomb': 0, 'Arrow': 0, 'Magic': 0}
required_rules = []
picky_enemies = []
hammer_required = False
bombs_required = False
for key, vln in vln_list.items():
if key[0].kind == EnemySprite.Terrorpin:
if not hammer_required:
required_rules.append(has('Hammer', player))
hammer_required = True
picky_enemies.append(key)
continue
if key[0].kind == EnemySprite.StalfosKnight:
if not bombs_required:
required_rules.append(bombs_rule(world, player, 1))
bombs_required = True
used_resources['Bomb'] += 1
picky_enemies.append(key)
continue
vln_types = [k for k in vln.keys() if vln[k] != 0]
if len(vln_types) == 1:
d_type = vln_types[0]
required_rules.append(defeat_rule_single(world, player, key[0], key[1]))
picky_enemies.append(key)
if d_type in damage_cost:
cost = damage_cost[d_type]
if d_type == 'Bomb':
used_resources['Bomb'] += cost
elif d_type in ['Bow', 'Silvers']:
used_resources['Arrow'] += cost
else:
used_resources['Magic'] += cost
vln_list = {k: v for k, v in vln_list.items() if k not in picky_enemies}
while len(vln_list) > 0:
optional_clears = find_possible_rules(vln_list, used_resources, world, player)
if len(optional_clears) == 0:
raise Exception('Kill rules seems to be insufficient for this enemy set, please report:'
+ ', '.join([str(x) for x, y in enemy_sprite_region_pairs]))
# find rules which kill the most
# idea: this could be multiple criteria: most-constrained then which method kills the most
best_rules = {}
best_size = 0
for vln_option in optional_clears.keys():
if len(vln_option) > best_size:
best_size = len(vln_option)
best_rules.clear()
best_rules[vln_option] = optional_clears[vln_option]
elif len(vln_option) == best_size: # assumes vln_option is different from prior options
best_rules[vln_option] = optional_clears[vln_option]
if len(best_rules) == 1:
vln_option, rule_pair_list = next(iter(best_rules.items()))
else:
vln_option, rule_pair_list = random.choice(list(best_rules.items()))
if best_size == 0:
raise Exception('Invulnerable enemy? rules seems to be insufficient for this enemy set, please report:'
+ ', '.join([str(x) for x, y in enemy_sprite_region_pairs]))
new_vln_list = {vln_kv[0]: vln_kv[1] for idx, vln_kv in enumerate(vln_list.items()) if idx not in vln_option}
rules_to_add = [rule for rule, resources in rule_pair_list]
resources_to_use = [resources for rule, resources in rule_pair_list]
required_rules.append(or_rule(*rules_to_add))
for r in resources_to_use:
for k, v in r.items():
used_resources[k] += v
vln_list = new_vln_list
return and_rule(*required_rules)
def find_possible_rules(vln_list, used_resources, world, player):
optional_clears = defaultdict(list)
blunt_marker = defaultdict(bool)
for damage_type in ['Blunt', 'Stun', 'Master', 'Boomerang', 'Hookshot']:
# all_vln = all(vln[damage_type] != 0 for vln in vln_list.values())
vln_sub_list = frozenset({idx for idx, vln in enumerate(vln_list.values()) if vln[damage_type] != 0})
if vln_sub_list:
if damage_type == 'Blunt':
optional_clears[vln_sub_list].append((has_blunt_weapon(player), {}))
blunt_marker[vln_sub_list] = True
if damage_type == 'Stun':
optional_clears[vln_sub_list].append((buzzblob_rule(player), {}))
if damage_type == 'Master' and not blunt_marker[vln_sub_list]:
optional_clears[vln_sub_list].append((has_class_2_weapon(player), {}))
if damage_type == 'Boomerang':
optional_clears[vln_sub_list].append((has('Hookshot', player), {}))
elif damage_type == 'Hookshot':
optional_clears[vln_sub_list].append((has_boomerang(player), {}))
damage_type = 'Bomb'
vln_sub_list = frozenset({idx for idx, vln in enumerate(vln_list.values()) if vln[damage_type] != 0})
if vln_sub_list:
hits = needed_resources(damage_type, vln_list)
if hits + used_resources['Bomb'] <= 8:
optional_clears[vln_sub_list].append(
(bombs_rule(world, player, hits + used_resources['Bomb']), {'Bomb': hits}))
for damage_type in ['Bow', 'Silvers']:
vln_sub_list = frozenset({idx for idx, vln in enumerate(vln_list.values()) if vln[damage_type] != 0})
if vln_sub_list:
hits = needed_resources(damage_type, vln_list)
resources = {'Arrow': hits}
if damage_type == 'Bow' and hits + used_resources['Arrow'] <= 25:
optional_clears[vln_sub_list].append(
(bow_rule(world, player, hits + used_resources['Arrow']), resources))
if damage_type == 'Silvers' and hits + used_resources['Arrow'] <= 25:
optional_clears[vln_sub_list].append(
(silvers_rule(world, player, hits + used_resources['Arrow']), resources))
for damage_type in ['Powder', 'Somaria', 'Byrna', 'FireRod', 'IceRod', 'Bombos', 'Ether', 'Quake']:
vln_sub_list = frozenset({idx for idx, vln in enumerate(vln_list.values()) if vln[damage_type] != 0})
if vln_sub_list:
hits = needed_resources(damage_type, vln_list)
resources = {'Magic': damage_cost[damage_type] * hits}
if damage_type == 'Powder' and math.ceil(hits / 16) * 8 + used_resources['Magic'] <= 160:
flag = min(vln[damage_type] for vln in vln_list.values())
flag = flag if flag < 0 else (hits + used_resources['Magic'] * 2)
optional_clears[vln_sub_list].append((magic_powder_rule(world, player, flag), resources))
elif damage_type == 'Somaria' and math.ceil(hits / 64) * 8 + used_resources['Magic'] <= 160:
flag = min(vln[damage_type] for vln in vln_list.values())
flag = flag if flag < 0 else (hits + used_resources['Magic'] * 8)
optional_clears[vln_sub_list].append((somaria_rule(world, player, flag), resources))
elif damage_type == 'Byrna' and math.ceil(hits / 7) * 8 + used_resources['Magic'] <= 160:
flag = min(vln[damage_type] for vln in vln_list.values())
flag = flag if flag < 0 else (hits + used_resources['Magic'] * 7 / 8)
optional_clears[vln_sub_list].append((byrna_rule(world, player, flag), resources))
elif damage_type == 'FireRod' and hits + used_resources['Magic'] <= 160:
flag = min(vln[damage_type] for vln in vln_list.values())
flag = flag if flag < 0 else (hits + used_resources['Magic'])
optional_clears[vln_sub_list].append((fire_rod_rule(world, player, flag), resources))
elif damage_type == 'IceRod' and hits + used_resources['Magic'] <= 160:
flag = min(vln[damage_type] for vln in vln_list.values())
flag = flag if flag < 0 else (hits + used_resources['Magic'])
optional_clears[vln_sub_list].append((ice_rod_rule(world, player, flag), resources))
elif hits * 2 + used_resources['Magic'] <= 160 and not blunt_marker[vln_sub_list]:
flag = min(vln[damage_type] for vln in vln_list.values())
flag = flag if flag < 0 else (hits + used_resources['Magic'] / 2)
optional_clears[vln_sub_list].append((medallion_rule(world, player, damage_type, flag), resources))
return optional_clears
def needed_resources(damage_type, vln_list):
return sum(vln[damage_type] if vln[damage_type] >= 0 else 1 for vln in vln_list.values() if vln[damage_type] != 0)
special_rules_check = {
'Swamp Waterway': None,
'Hera Back': [5, 6],
'GT Petting Zoo': [5, 8, 9, 11],
'Mimic Cave': [4, 5, 6, 7],
'Ice Hookshot Ledge': None,
'TR Hub Ledges': [3, 4, 5, 6, 7],
'TR Dark Ride': [1, 2, 3],
'GT Speed Torch': [11, 13],
'Old Man Cave (West)': None,
'Old Man Cave (East)': None,
'Old Man House': [1, 3],
'Old Man House Back': [4, 5, 6],
'Death Mountain Return Cave (left)': None,
'Death Mountain Return Cave (right)': [1, 2, 3, 6, 7], # 2, 5, 6 are considered embedded
'Hookshot Fairy': [0, 1, 2, 3]
}
def special_rules_for_region(world, player, region_name, location, original_rule):
if region_name == 'Swamp Waterway':
return or_rule(medallion_rule(world, player, 'Quake', 1),
medallion_rule(world, player, 'Ether', 1),
medallion_rule(world, player, 'Bombos', 1))
elif region_name in ['Hera Back', 'GT Petting Zoo', 'Mimic Cave']:
enemy_number = int(location.name.split('#')[1])
if region_name == 'Mimic Cave':
if enemy_number in [4, 5]: # these are behind hammer blocks potentially
return and_rule(original_rule, has('Hammer', player))
elif enemy_number in special_rules_check[region_name]: # these are behind rails
return and_rule(original_rule, has_boomerang(player))
if enemy_number in special_rules_check[region_name]:
return and_rule(original_rule, has_boomerang(player))
else:
return original_rule
elif region_name in ['TR Hub Ledges', 'Ice Hookshot Ledge', 'TR Dark Ride']:
enemy_number = int(location.name.split('#')[1])
if special_rules_check[region_name] is None or enemy_number in special_rules_check[region_name]:
return and_rule(original_rule, or_rule(has_boomerang(player), has('Hookshot', player)))
else:
return original_rule
elif region_name in ['Old Man Cave (West)', 'Old Man Cave (East)', 'Old Man House Back', 'Old Man House',
'Death Mountain Return Cave (left)', 'Death Mountain Return Cave (right)',
'Hookshot Fairy']:
enemy_number = int(location.name.split('#')[1])
if region_name == 'Death Mountain Return Cave (left)':
if enemy_number in [1, 5]:
return and_rule(original_rule, or_rule(has_boomerang(player), has('Hookshot', player)))
else:
return and_rule(original_rule, has('Hookshot', player))
if special_rules_check[region_name] is None or enemy_number in special_rules_check[region_name]:
return and_rule(original_rule, has('Hookshot', player))
else:
return original_rule
return original_rule
def has_blunt_weapon(player):
return or_rule(has_sword(player), has('Hammer', player))
# Bombs, Arrows, Bombos, FireRod, Somaria, Byrna should be handled by the damage table logic
# Powder doesn't work (also handled by damage table)
def buzzblob_rule(player):
return or_rule(has('Golden Sword', player),
and_rule(has_blunt_weapon(player),
or_rule(has_boomerang(player), has('Hookshot', player), has('Ice Rod', player))),
and_rule(has('Ether', player), has_sword(player)),
and_rule(has('Quake', player), has_sword(player)))
def has_class_2_weapon(player):
return or_rule(has_beam_sword(player), has('Hammer', player))
def somaria_rule(world, player, somaria_hits):
if somaria_hits == -1:
return has('Cane of Somaria', player) # insta-kill somaria? - not in vanilla
else:
magic_needed = math.ceil(somaria_hits / 64) * 8 # 64 hits per magic bar - 80 max?
if magic_needed > 8:
return and_rule(has('Cane of Somaria', player), can_extend_magic(world, player, magic_needed))
else:
return has('Cane of Somaria', player)
def byrna_rule(world, player, byrna_hits):
if byrna_hits == -1:
return has('Cane of Byrna', player) # insta-kill byrna? - not in vanilla
else:
magic_needed = math.ceil(byrna_hits / 7) * 8 # 7 hits per magic bar - generous?
if magic_needed > 8:
return and_rule(has('Cane of Byrna', player), can_extend_magic(world, player, magic_needed))
else:
return has('Cane of Byrna', player)
def bow_rule(world, player, arrows):
if arrows == -1 or 0 < arrows <= 25:
return can_shoot_normal_arrows(world, player)
return RuleFactory.static_rule(False)
def silvers_rule(world, player, arrows):
if arrows == -1 or 0 < arrows <= 25:
return can_shoot_silver_arrows(world, player)
return RuleFactory.static_rule(False)
def bombs_rule(world, player, bombs):
if bombs == -1 or 0 < bombs <= 8:
return can_use_bombs(world, player)
return RuleFactory.static_rule(False)
def ice_rod_rule(world, player, shots):
if shots == -1:
return has('Ice Rod', player)
if shots > 8:
return and_rule(has('Ice Rod', player), can_extend_magic(world, player, shots))
else:
return has('Ice Rod', player)
def fire_rod_rule(world, player, shots):
if shots == -1:
return has('Fire Rod', player)
if shots > 8:
return and_rule(has('Fire Rod', player), can_extend_magic(world, player, shots))
else:
return has('Fire Rod', player)
def magic_powder_rule(world, player, shots):
if shots == -1:
return has('Magic Powder', player)
if shots == -2:
# todo: other resources possible I guess - harder to keep track of though
return and_rule(has('Magic Powder', player), or_rule(has_blunt_weapon(player), has('Hookshot', player)))
magic_needed = math.ceil(shots / 16) * 8 # 16 tries per magic bar, that could be tight...
if magic_needed > 8:
return and_rule(has('Magic Powder', player), can_extend_magic(world, player, shots))
else:
return has('Magic Powder', player)
def medallion_rule(world, player, medallion, shots):
if shots == -1:
return and_rule(has(medallion, player), has_sword(player))
if shots == -2:
return and_rule(has(medallion, player), has_sword(player))
magic_needed = shots * 2
if magic_needed > 8:
return and_rule(has(medallion, player), has_sword(player), can_extend_magic(world, player, shots))
else:
return and_rule(has(medallion, player), has_sword(player))
def or_rule(*rules):
return RuleFactory.disj(rules)
def and_rule(*rules):
return RuleFactory.conj(rules)
def has(item, player, count=1):
return RuleFactory.item(item, player, count)
def has_sword(player):
return or_rule(
has('Fighter Sword', player), has('Master Sword', player),
has('Tempered Sword', player), has('Golden Sword', player)
)
def has_beam_sword(player):
return or_rule(
has('Master Sword', player), has('Tempered Sword', player), has('Golden Sword', player)
)
def has_class_3_sword(player):
return or_rule(
has('Tempered Sword', player), has('Golden Sword', player)
)
def can_extend_magic(world, player, magic, flag_t=False):
potion_shops = (find_shops_that_sell('Blue Potion', world, player) |
find_shops_that_sell('Green Potion', world, player))
return RuleFactory.extend_magic(player, magic, world.difficulty_adjustments[player], potion_shops, flag_t)
# class 0 damage (subtypes 1 and 2)
def has_boomerang(player):
return or_rule(has('Blue Boomerang', player), has('Red_Boomerang', player))
def find_shops_that_sell(item, world, player):
return {shop.region for shop in world.shops[player] if shop.has_unlimited(item) and shop.region.player == player}
def can_shoot_normal_arrows(world, player):
if world.bow_mode[player].startswith('retro'):
shops = find_shops_that_sell('Single Arrow', world, player)
# retro+shopsanity, shops may not sell the Single Arrow at all
if world.bow_mode[player] == 'retro_silvers':
# non-progressive silvers grant wooden arrows, so shop may not be needed
return and_rule(has('Bow', player), or_rule(RuleFactory.unlimited('Single Arrow', player, shops),
has('Single Arrow', player), has('Silver Arrows', player)))
else:
return and_rule(has('Bow', player), or_rule(RuleFactory.unlimited('Single Arrow', player, shops),
has('Single Arrow', player)))
return has('Bow', player)
def can_shoot_silver_arrows(world, player):
# retro_silver requires the silver arrows item which is sufficient for the quiver
if world.bow_mode[player] == 'retro':
shops = find_shops_that_sell('Single Arrow', world, player)
# retro+shopsanity, shops may not sell the Single Arrow at all
return and_rule(has('Silver Arrows', player), or_rule(RuleFactory.unlimited('Single Arrow', player, shops),
has('Single Arrow', player)))
return and_rule(has('Bow', player), has('Silver Arrows', player))
def can_use_bombs(world, player):
return or_rule(RuleFactory.static_rule(not world.bombbag[player]), has('Bomb Upgrade (+10)', player))
def enemy_vulnerability(world, player, enemy_sprite, region):
damage_table = world.damage_table[player].damage_table
stats = world.data_tables[player].enemy_stats
damage_src = damage_table['DamageSource']
sub_class_table = damage_table['SubClassTable']
enemy_sub_class = sub_class_table[enemy_sprite.kind]
vulnerability = defaultdict(int)
c1 = number_of_hits('Sword1', damage_src, enemy_sub_class, stats, enemy_sprite, region)
if c1 != 0:
if enemy_sprite.kind == EnemySprite.Buzzblob:
vulnerability['Stun'] = -1
else:
vulnerability['Blunt'] = -1
vulnerability['Master'] = -1
vulnerability['Somaria'] = c1
vulnerability['Byrna'] = c1
else:
c2 = number_of_hits('Sword3', damage_src, enemy_sub_class, stats, enemy_sprite, region)
if c2 != 0:
vulnerability['Master'] = -1 # currently Lynels are only vulnerable to only master spins or above
hits = number_of_hits('Arrow', damage_src, enemy_sub_class, stats, enemy_sprite, region)
if hits != 0:
vulnerability['Bow'] = hits
hits = number_of_hits('SilverArrow', damage_src, enemy_sub_class, stats, enemy_sprite, region)
if hits != 0:
vulnerability['Silvers'] = hits
for method in ['Bomb', 'Hookshot', 'FireRod', 'IceRod', 'Boomerang', 'Powder', 'Bombos', 'Ether', 'Quake']:
hits = number_of_hits(method, damage_src, enemy_sub_class, stats, enemy_sprite, region)
if hits == -3:
if enemy_sprite.kind != EnemySprite.Buzzblob: # buzzblobs are special and don't die
vulnerability[method] = -1
elif hits != 0:
vulnerability[method] = hits
return vulnerability
def number_of_hits(source_name, damage_src, enemy_sub_class, stats, enemy_sprite, region):
damage_class = damage_src[source_name]['class']
sub_class = enemy_sub_class[damage_class]
damage_amount = damage_src[source_name]['subclass'][sub_class]
if damage_amount == 0:
return 0
elif damage_amount <= 0x64:
health = stats[enemy_sprite.kind].health
if isinstance(health, tuple):
if enemy_sprite.kind in [EnemySprite.Tektite, EnemySprite.HardhatBeetle]:
idx = enemy_sprite.tile_x & 0x1
health = health[idx]
elif region.is_light_world and region.is_dark_world:
health = min(health)
elif region.is_light_world:
health = health[0]
elif region.is_dark_world:
health = health[1]
else:
health = max(health)
return math.ceil(health / damage_amount)
elif damage_amount in [0xF9, 0xFA, 0xFD]:
# -1 incinerated; -2 blobbed, -3 fairy-ed (depends on enemy if "killed" or not)
# F9: fairy, defeated, but doesn't drop anything: -3
# FA: blobbed - can you kill a blob? = -2
# FD: incinerated
return special_classes[damage_amount]
else:
return 0
special_classes = {0xF9: -3, 0xFA: -2, 0xFD: -1}