mirror of
https://github.com/MarioSpore/Grinch-AP.git
synced 2025-10-21 20:21:32 -06:00
TUNIC: Combat logic fix (#4589)
* Potential fix for attack issue * also put the lazy version of the swamp fix in for good measure * fix extra line * now it is good * Add the test, roll the other PR into this one * Make the test exception more useful * Remove debug print * Combat logic fixed? * Move a few areas to before well instead of east forest * Put in qwint's suggestions in test * Implement qwint's suggestions in combat_logic.py * Implement qwint's suggestions for combat_logic.py * Fix typo * Remove experimental from combat logic description * Remove copy_mixin again * Add comment about copy_mixin * Use a more proper random * Some optimizations from Vi's comments
This commit is contained in:
@@ -8,6 +8,7 @@ from worlds.AutoWorld import LogicMixin
|
||||
|
||||
# the vanilla stats you are expected to have to get through an area, based on where they are in vanilla
|
||||
class AreaStats(NamedTuple):
|
||||
"""Attack, Defense, Potion, HP, SP, MP, Flasks, Equipment, is_boss"""
|
||||
att_level: int
|
||||
def_level: int
|
||||
potion_level: int # all 3 are before your first bonfire after getting the upgrade page, third costs 1k
|
||||
@@ -41,7 +42,7 @@ area_data: Dict[str, AreaStats] = {
|
||||
"Rooted Ziggurat": AreaStats(5, 5, 3, 5, 3, 3, 6, ["Sword", "Shield", "Magic"]),
|
||||
"Boss Scavenger": AreaStats(5, 5, 3, 5, 3, 3, 6, ["Sword", "Shield", "Magic"], is_boss=True),
|
||||
"Swamp": AreaStats(1, 1, 1, 1, 1, 1, 6, ["Sword", "Shield", "Magic"]),
|
||||
"Cathedral": AreaStats(1, 1, 1, 1, 1, 1, 6, ["Sword", "Shield", "Magic"]),
|
||||
# Cathedral has the same requirements as Swamp
|
||||
# marked as boss because the garden knights can't get hurt by stick
|
||||
"Gauntlet": AreaStats(1, 1, 1, 1, 1, 1, 6, ["Sword", "Shield", "Magic"], is_boss=True),
|
||||
"The Heir": AreaStats(5, 5, 3, 5, 3, 3, 6, ["Sword", "Shield", "Magic", "Laurels"], is_boss=True),
|
||||
@@ -49,8 +50,10 @@ area_data: Dict[str, AreaStats] = {
|
||||
|
||||
|
||||
# these are used for caching which areas can currently be reached in state
|
||||
# Gauntlet does not have exclusively higher stat requirements, so it will be checked separately
|
||||
boss_areas: List[str] = [name for name, data in area_data.items() if data.is_boss and name != "Gauntlet"]
|
||||
non_boss_areas: List[str] = [name for name, data in area_data.items() if not data.is_boss]
|
||||
# Swamp does not have exclusively higher stat requirements, so it will be checked separately
|
||||
non_boss_areas: List[str] = [name for name, data in area_data.items() if not data.is_boss and name != "Swamp"]
|
||||
|
||||
|
||||
class CombatState(IntEnum):
|
||||
@@ -89,6 +92,7 @@ def has_combat_reqs(area_name: str, state: CollectionState, player: int) -> bool
|
||||
elif area_name in non_boss_areas:
|
||||
area_list = non_boss_areas
|
||||
else:
|
||||
# this is to check Swamp and Gauntlet on their own
|
||||
area_list = [area_name]
|
||||
|
||||
if met_combat_reqs:
|
||||
@@ -114,88 +118,99 @@ def check_combat_reqs(area_name: str, state: CollectionState, player: int, alt_d
|
||||
extra_att_needed = 0
|
||||
extra_def_needed = 0
|
||||
extra_mp_needed = 0
|
||||
has_magic = state.has_any({"Magic Wand", "Gun"}, player)
|
||||
stick_bool = False
|
||||
sword_bool = False
|
||||
has_magic = state.has_any(("Magic Wand", "Gun"), player)
|
||||
sword_bool = has_sword(state, player)
|
||||
stick_bool = sword_bool or has_melee(state, player)
|
||||
equipment = data.equipment.copy()
|
||||
for item in data.equipment:
|
||||
if item == "Stick":
|
||||
if not has_melee(state, player):
|
||||
if not stick_bool:
|
||||
if has_magic:
|
||||
equipment.remove("Stick")
|
||||
if "Magic" not in equipment:
|
||||
equipment.append("Magic")
|
||||
# magic can make up for the lack of stick
|
||||
extra_mp_needed += 2
|
||||
extra_att_needed -= 16
|
||||
extra_att_needed -= 32
|
||||
else:
|
||||
return False
|
||||
else:
|
||||
stick_bool = True
|
||||
|
||||
elif item == "Sword":
|
||||
if not has_sword(state, player):
|
||||
if not sword_bool:
|
||||
# need sword for bosses
|
||||
if data.is_boss:
|
||||
return False
|
||||
equipment.remove("Sword")
|
||||
if has_magic:
|
||||
if "Magic" not in equipment:
|
||||
equipment.append("Magic")
|
||||
# +4 mp pretty much makes up for the lack of sword, at least in Quarry
|
||||
extra_mp_needed += 4
|
||||
# stick is a backup plan, and doesn't scale well, so let's require a little less
|
||||
extra_att_needed -= 2
|
||||
elif has_melee(state, player):
|
||||
if stick_bool:
|
||||
# stick is a backup plan, and doesn't scale well, so let's require a little less
|
||||
equipment.append("Stick")
|
||||
extra_att_needed -= 2
|
||||
else:
|
||||
extra_mp_needed += 2
|
||||
extra_att_needed -= 32
|
||||
elif stick_bool:
|
||||
equipment.append("Stick")
|
||||
# may revise this later based on feedback
|
||||
extra_att_needed += 3
|
||||
extra_def_needed += 2
|
||||
else:
|
||||
return False
|
||||
else:
|
||||
sword_bool = True
|
||||
|
||||
# just increase the stat requirement, we'll check for shield when calculating defense
|
||||
elif item == "Shield":
|
||||
if not state.has("Shield", player):
|
||||
extra_def_needed += 2
|
||||
equipment.remove("Shield")
|
||||
extra_def_needed += 2
|
||||
|
||||
elif item == "Laurels":
|
||||
if not state.has("Hero's Laurels", player):
|
||||
# these are entirely based on vibes
|
||||
extra_att_needed += 2
|
||||
extra_def_needed += 3
|
||||
# require Laurels for the Heir
|
||||
return False
|
||||
|
||||
elif item == "Magic":
|
||||
if not has_magic:
|
||||
equipment.remove("Magic")
|
||||
extra_att_needed += 2
|
||||
extra_def_needed += 2
|
||||
extra_mp_needed -= 16
|
||||
extra_mp_needed -= 32
|
||||
|
||||
modified_stats = AreaStats(data.att_level + extra_att_needed, data.def_level + extra_def_needed, data.potion_level,
|
||||
data.hp_level, data.sp_level, data.mp_level + extra_mp_needed, data.potion_count)
|
||||
if not has_required_stats(modified_stats, state, player):
|
||||
data.hp_level, data.sp_level, data.mp_level + extra_mp_needed, data.potion_count,
|
||||
equipment, data.is_boss)
|
||||
if has_required_stats(modified_stats, state, player):
|
||||
return True
|
||||
else:
|
||||
# we may need to check if you would have the required stats if you were missing a weapon
|
||||
# it's kinda janky, but these only get hit in less than once per 100 generations, so whatever
|
||||
if sword_bool and "Sword" in data.equipment and "Magic" in data.equipment:
|
||||
# we need to check if you would have the required stats if you didn't have melee
|
||||
equip_list = [item for item in data.equipment if item != "Sword"]
|
||||
more_modified_stats = AreaStats(data.att_level - 16, data.def_level, data.potion_level,
|
||||
data.hp_level, data.sp_level, data.mp_level + 4, data.potion_count,
|
||||
equip_list)
|
||||
if sword_bool and "Sword" in equipment and has_magic:
|
||||
# we need to check if you would have the required stats if you didn't have the sword
|
||||
equip_list = [item for item in equipment if item != "Sword"]
|
||||
if "Magic" not in equip_list:
|
||||
equip_list.append("Magic")
|
||||
more_modified_stats = AreaStats(modified_stats.att_level - 32, modified_stats.def_level,
|
||||
modified_stats.potion_level, modified_stats.hp_level,
|
||||
modified_stats.sp_level, modified_stats.mp_level + 4,
|
||||
modified_stats.potion_count, equip_list, data.is_boss)
|
||||
if check_combat_reqs("none", state, player, more_modified_stats):
|
||||
return True
|
||||
|
||||
# and we need to check if you would have the required stats if you didn't have magic
|
||||
equip_list = [item for item in data.equipment if item != "Magic"]
|
||||
more_modified_stats = AreaStats(data.att_level + 2, data.def_level + 2, data.potion_level,
|
||||
data.hp_level, data.sp_level, data.mp_level - 16, data.potion_count,
|
||||
equip_list)
|
||||
if check_combat_reqs("none", state, player, more_modified_stats):
|
||||
return True
|
||||
return False
|
||||
|
||||
elif stick_bool and "Stick" in data.equipment and "Magic" in data.equipment:
|
||||
elif stick_bool and "Stick" in equipment and has_magic:
|
||||
# we need to check if you would have the required stats if you didn't have the stick
|
||||
equip_list = [item for item in data.equipment if item != "Stick"]
|
||||
more_modified_stats = AreaStats(data.att_level - 16, data.def_level, data.potion_level,
|
||||
data.hp_level, data.sp_level, data.mp_level + 4, data.potion_count,
|
||||
equip_list)
|
||||
equip_list = [item for item in equipment if item != "Stick"]
|
||||
if "Magic" not in equip_list:
|
||||
equip_list.append("Magic")
|
||||
more_modified_stats = AreaStats(modified_stats.att_level - 32, modified_stats.def_level,
|
||||
modified_stats.potion_level, modified_stats.hp_level,
|
||||
modified_stats.sp_level, modified_stats.mp_level + 4,
|
||||
modified_stats.potion_count, equip_list, data.is_boss)
|
||||
if check_combat_reqs("none", state, player, more_modified_stats):
|
||||
return True
|
||||
return False
|
||||
else:
|
||||
return False
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# check if you have the required stats, and the money to afford them
|
||||
@@ -203,72 +218,63 @@ def check_combat_reqs(area_name: str, state: CollectionState, player: int, alt_d
|
||||
# but that's fine -- it's already pretty generous to begin with
|
||||
def has_required_stats(data: AreaStats, state: CollectionState, player: int) -> bool:
|
||||
money_required = 0
|
||||
player_att = 0
|
||||
att_required = data.att_level
|
||||
player_att, att_offerings = get_att_level(state, player)
|
||||
|
||||
# check if we actually need the stat before checking state
|
||||
if data.att_level > 1:
|
||||
player_att, att_offerings = get_att_level(state, player)
|
||||
if player_att < data.att_level:
|
||||
return False
|
||||
# if you have 2 more attack than needed, we can forego needing mp
|
||||
if data.mp_level > 1:
|
||||
if player_att < data.att_level + 2:
|
||||
player_mp, mp_offerings = get_mp_level(state, player)
|
||||
if player_mp < data.mp_level:
|
||||
return False
|
||||
else:
|
||||
extra_mp = player_mp - data.mp_level
|
||||
paid_mp = max(0, mp_offerings - extra_mp)
|
||||
# mp costs 300 for the first, +50 for each additional
|
||||
money_per_mp = 300
|
||||
for _ in range(paid_mp):
|
||||
money_required += money_per_mp
|
||||
money_per_mp += 50
|
||||
else:
|
||||
extra_att = player_att - data.att_level
|
||||
paid_att = max(0, att_offerings - extra_att)
|
||||
# attack upgrades cost 100 for the first, +50 for each additional
|
||||
money_per_att = 100
|
||||
for _ in range(paid_att):
|
||||
money_required += money_per_att
|
||||
money_per_att += 50
|
||||
att_required += 2
|
||||
|
||||
if player_att < att_required:
|
||||
return False
|
||||
else:
|
||||
extra_att = player_att - att_required
|
||||
paid_att = max(0, att_offerings - extra_att)
|
||||
# attack upgrades cost 100 for the first, +50 for each additional
|
||||
money_per_att = 100
|
||||
for _ in range(paid_att):
|
||||
money_required += money_per_att
|
||||
money_per_att += 50
|
||||
|
||||
# adding defense and sp together since they accomplish similar things: making you take less damage
|
||||
if data.def_level + data.sp_level > 2:
|
||||
player_def, def_offerings = get_def_level(state, player)
|
||||
player_sp, sp_offerings = get_sp_level(state, player)
|
||||
if player_def + player_sp < data.def_level + data.sp_level:
|
||||
req_stats = data.def_level + data.sp_level
|
||||
if player_def + player_sp < req_stats:
|
||||
return False
|
||||
else:
|
||||
free_def = player_def - def_offerings
|
||||
free_sp = player_sp - sp_offerings
|
||||
paid_stats = data.def_level + data.sp_level - free_def - free_sp
|
||||
sp_to_buy = 0
|
||||
|
||||
if paid_stats <= 0:
|
||||
# if you don't have to pay for any stats, you don't need money for these upgrades
|
||||
def_to_buy = 0
|
||||
elif paid_stats <= def_offerings:
|
||||
# get the amount needed to buy these def offerings
|
||||
def_to_buy = paid_stats
|
||||
if free_sp + free_def >= req_stats:
|
||||
# you don't need to buy upgrades
|
||||
pass
|
||||
else:
|
||||
def_to_buy = def_offerings
|
||||
sp_to_buy = max(0, paid_stats - def_offerings)
|
||||
|
||||
# if you have to buy more than 3 def, it's cheaper to buy 1 extra sp
|
||||
if def_to_buy > 3 and sp_offerings > 0:
|
||||
def_to_buy -= 1
|
||||
sp_to_buy += 1
|
||||
# def costs 100 for the first, +50 for each additional
|
||||
money_per_def = 100
|
||||
for _ in range(def_to_buy):
|
||||
money_required += money_per_def
|
||||
money_per_def += 50
|
||||
# sp costs 200 for the first, +200 for each additional
|
||||
money_per_sp = 200
|
||||
for _ in range(sp_to_buy):
|
||||
money_required += money_per_sp
|
||||
money_per_sp += 200
|
||||
|
||||
# if you have 2 more attack than needed, we can forego needing mp
|
||||
if data.mp_level > 1 and player_att < data.att_level + 2:
|
||||
player_mp, mp_offerings = get_mp_level(state, player)
|
||||
if player_mp < data.mp_level:
|
||||
return False
|
||||
else:
|
||||
extra_mp = player_mp - data.mp_level
|
||||
paid_mp = max(0, mp_offerings - extra_mp)
|
||||
# mp costs 300 for the first, +50 for each additional
|
||||
money_per_mp = 300
|
||||
for _ in range(paid_mp):
|
||||
money_required += money_per_mp
|
||||
money_per_mp += 50
|
||||
# we need to pick the cheapest option that gets us above the stats we need
|
||||
# first number is def, second number is sp
|
||||
upgrade_options: set[tuple[int, int]] = set()
|
||||
stats_to_buy = req_stats - free_def - free_sp
|
||||
for paid_def in range(0, min(def_offerings + 1, stats_to_buy + 1)):
|
||||
sp_required = stats_to_buy - paid_def
|
||||
if sp_offerings >= sp_required:
|
||||
if sp_required < 0:
|
||||
break
|
||||
upgrade_options.add((paid_def, stats_to_buy - paid_def))
|
||||
costs = [calc_def_sp_cost(defense, sp) for defense, sp in upgrade_options]
|
||||
money_required += min(costs)
|
||||
|
||||
req_effective_hp = calc_effective_hp(data.hp_level, data.potion_level, data.potion_count)
|
||||
player_potion, potion_offerings = get_potion_level(state, player)
|
||||
@@ -279,53 +285,30 @@ def has_required_stats(data: AreaStats, state: CollectionState, player: int) ->
|
||||
return False
|
||||
else:
|
||||
# need a way to determine which of potion offerings or hp offerings you can reduce
|
||||
# your level if you didn't pay for offerings
|
||||
free_potion = player_potion - potion_offerings
|
||||
free_hp = player_hp - hp_offerings
|
||||
paid_hp_count = 0
|
||||
paid_potion_count = 0
|
||||
if calc_effective_hp(free_hp, free_potion, player_potion_count) >= req_effective_hp:
|
||||
# you don't need to buy upgrades
|
||||
pass
|
||||
# if you have no potions, or no potion upgrades, you only need to check your hp upgrades
|
||||
elif player_potion_count == 0 or potion_offerings == 0:
|
||||
# check if you have enough hp at each paid hp offering
|
||||
for i in range(hp_offerings):
|
||||
paid_hp_count = i + 1
|
||||
if calc_effective_hp(paid_hp_count, 0, player_potion_count) > req_effective_hp:
|
||||
break
|
||||
else:
|
||||
for i in range(potion_offerings):
|
||||
paid_potion_count = i + 1
|
||||
if calc_effective_hp(free_hp, free_potion + paid_potion_count, player_potion_count) > req_effective_hp:
|
||||
break
|
||||
for j in range(hp_offerings):
|
||||
paid_hp_count = j + 1
|
||||
if (calc_effective_hp(free_hp + paid_hp_count, free_potion + paid_potion_count, player_potion_count)
|
||||
> req_effective_hp):
|
||||
# we need to pick the cheapest option that gets us above the amount of effective HP we need
|
||||
# first number is hp, second number is potion
|
||||
upgrade_options: set[tuple[int, int]] = set()
|
||||
# filter out exclusively worse options
|
||||
lowest_hp_added = hp_offerings + 1
|
||||
for paid_potion in range(0, potion_offerings + 1):
|
||||
# check quantities of hp offerings for each potion offering
|
||||
for paid_hp in range(0, lowest_hp_added):
|
||||
if (calc_effective_hp(free_hp + paid_hp, free_potion + paid_potion, player_potion_count)
|
||||
>= req_effective_hp):
|
||||
upgrade_options.add((paid_hp, paid_potion))
|
||||
lowest_hp_added = paid_hp
|
||||
break
|
||||
# hp costs 200 for the first, +50 for each additional
|
||||
money_per_hp = 200
|
||||
for _ in range(paid_hp_count):
|
||||
money_required += money_per_hp
|
||||
money_per_hp += 50
|
||||
|
||||
# potion costs 100 for the first, 300 for the second, 1,000 for the third, and +200 for each additional
|
||||
# currently we assume you will not buy past the second potion upgrade, but we might change our minds later
|
||||
money_per_potion = 100
|
||||
for _ in range(paid_potion_count):
|
||||
money_required += money_per_potion
|
||||
if money_per_potion == 100:
|
||||
money_per_potion = 300
|
||||
elif money_per_potion == 300:
|
||||
money_per_potion = 1000
|
||||
else:
|
||||
money_per_potion += 200
|
||||
costs = [calc_hp_potion_cost(hp, potion) for hp, potion in upgrade_options]
|
||||
money_required += min(costs)
|
||||
|
||||
if money_required > get_money_count(state, player):
|
||||
return False
|
||||
|
||||
return True
|
||||
return get_money_count(state, player) >= money_required
|
||||
|
||||
|
||||
# returns a tuple of your max attack level, the number of attack offerings
|
||||
@@ -336,7 +319,8 @@ def get_att_level(state: CollectionState, player: int) -> Tuple[int, int]:
|
||||
if sword_level >= 3:
|
||||
att_upgrades += min(2, sword_level - 2)
|
||||
# attack falls off, can just cap it at 8 for simplicity
|
||||
return min(8, 1 + att_offerings + att_upgrades), att_offerings
|
||||
return (min(8, 1 + att_offerings + att_upgrades)
|
||||
+ (1 if state.has("Hero's Laurels", player) else 0), att_offerings)
|
||||
|
||||
|
||||
# returns a tuple of your max defense level, the number of defense offerings
|
||||
@@ -344,7 +328,9 @@ def get_def_level(state: CollectionState, player: int) -> Tuple[int, int]:
|
||||
def_offerings = state.count("DEF Offering", player)
|
||||
# defense falls off, can just cap it at 8 for simplicity
|
||||
return (min(8, 1 + def_offerings
|
||||
+ state.count_from_list({"Hero Relic - DEF", "Secret Legend", "Phonomath"}, player)),
|
||||
+ state.count_from_list({"Hero Relic - DEF", "Secret Legend", "Phonomath"}, player))
|
||||
+ (2 if state.has("Shield", player) else 0)
|
||||
+ (2 if state.has("Hero's Laurels", player) else 0),
|
||||
def_offerings)
|
||||
|
||||
|
||||
@@ -408,6 +394,46 @@ def get_money_count(state: CollectionState, player: int) -> int:
|
||||
return money
|
||||
|
||||
|
||||
def calc_hp_potion_cost(hp_upgrades: int, potion_upgrades: int) -> int:
|
||||
money = 0
|
||||
|
||||
# hp costs 200 for the first, +50 for each additional
|
||||
money_per_hp = 200
|
||||
for _ in range(hp_upgrades):
|
||||
money += money_per_hp
|
||||
money_per_hp += 50
|
||||
|
||||
# potion costs 100 for the first, 300 for the second, 1,000 for the third, and +200 for each additional
|
||||
# currently we assume you will not buy past the second potion upgrade, but we might change our minds later
|
||||
money_per_potion = 100
|
||||
for _ in range(potion_upgrades):
|
||||
money += money_per_potion
|
||||
if money_per_potion == 100:
|
||||
money_per_potion = 300
|
||||
elif money_per_potion == 300:
|
||||
money_per_potion = 1000
|
||||
else:
|
||||
money_per_potion += 200
|
||||
|
||||
return money
|
||||
|
||||
|
||||
def calc_def_sp_cost(def_upgrades: int, sp_upgrades: int) -> int:
|
||||
money = 0
|
||||
|
||||
money_per_def = 100
|
||||
for _ in range(def_upgrades):
|
||||
money += money_per_def
|
||||
money_per_def += 50
|
||||
|
||||
money_per_sp = 200
|
||||
for _ in range(sp_upgrades):
|
||||
money += money_per_sp
|
||||
money_per_sp += 200
|
||||
|
||||
return money
|
||||
|
||||
|
||||
class TunicState(LogicMixin):
|
||||
tunic_need_to_reset_combat_from_collect: Dict[int, bool]
|
||||
tunic_need_to_reset_combat_from_remove: Dict[int, bool]
|
||||
@@ -420,3 +446,5 @@ class TunicState(LogicMixin):
|
||||
self.tunic_need_to_reset_combat_from_remove = defaultdict(lambda: False)
|
||||
# the per-player, per-area state of combat checking -- unchecked, failed, or succeeded
|
||||
self.tunic_area_combat_state = defaultdict(lambda: defaultdict(lambda: CombatState.unchecked))
|
||||
# a copy_mixin was intentionally excluded because the empty state from init_mixin
|
||||
# will always be appropriate for recalculating the logic cache
|
||||
|
@@ -1386,9 +1386,9 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
|
||||
# need to fight through the rudelings and turret, or just laurels from near the windmill
|
||||
set_rule(ow_to_well_entry,
|
||||
lambda state: state.has(laurels, player)
|
||||
or has_combat_reqs("East Forest", state, player))
|
||||
or has_combat_reqs("Before Well", state, player))
|
||||
set_rule(ow_tunnel_beach,
|
||||
lambda state: has_combat_reqs("East Forest", state, player))
|
||||
lambda state: has_combat_reqs("Before Well", state, player))
|
||||
|
||||
add_rule(atoll_statue,
|
||||
lambda state: has_combat_reqs("Ruined Atoll", state, player))
|
||||
@@ -1467,12 +1467,12 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
|
||||
set_rule(cath_entry_to_elev,
|
||||
lambda state: options.entrance_rando
|
||||
or has_ice_grapple_logic(False, IceGrappling.option_medium, state, world)
|
||||
or (has_ability(prayer, state, world) and has_combat_reqs("Cathedral", state, player)))
|
||||
or (has_ability(prayer, state, world) and has_combat_reqs("Swamp", state, player)))
|
||||
|
||||
set_rule(cath_entry_to_main,
|
||||
lambda state: has_combat_reqs("Cathedral", state, player))
|
||||
lambda state: has_combat_reqs("Swamp", state, player))
|
||||
set_rule(cath_elev_to_main,
|
||||
lambda state: has_combat_reqs("Cathedral", state, player))
|
||||
lambda state: has_combat_reqs("Swamp", state, player))
|
||||
|
||||
# for spots where you can go into and come out of an entrance to reset enemy aggro
|
||||
if world.options.entrance_rando:
|
||||
@@ -1835,10 +1835,10 @@ def set_er_location_rules(world: "TunicWorld") -> None:
|
||||
combat_logic_to_loc("Overworld - [Northeast] Chest Above Patrol Cave", "Garden Knight", dagger=True)
|
||||
combat_logic_to_loc("Overworld - [Southwest] West Beach Guarded By Turret", "Overworld", dagger=True)
|
||||
combat_logic_to_loc("Overworld - [Southwest] West Beach Guarded By Turret 2", "Overworld")
|
||||
combat_logic_to_loc("Overworld - [Southwest] Bombable Wall Near Fountain", "East Forest", dagger=True)
|
||||
combat_logic_to_loc("Overworld - [Southwest] Fountain Holy Cross", "East Forest", dagger=True)
|
||||
combat_logic_to_loc("Overworld - [Southwest] South Chest Near Guard", "East Forest", dagger=True)
|
||||
combat_logic_to_loc("Overworld - [Southwest] Tunnel Guarded By Turret", "East Forest", dagger=True)
|
||||
combat_logic_to_loc("Overworld - [Southwest] Bombable Wall Near Fountain", "Before Well", dagger=True)
|
||||
combat_logic_to_loc("Overworld - [Southwest] Fountain Holy Cross", "Before Well", dagger=True)
|
||||
combat_logic_to_loc("Overworld - [Southwest] South Chest Near Guard", "Before Well", dagger=True)
|
||||
combat_logic_to_loc("Overworld - [Southwest] Tunnel Guarded By Turret", "Before Well", dagger=True)
|
||||
combat_logic_to_loc("Overworld - [Northwest] Chest Near Turret", "Before Well")
|
||||
|
||||
add_rule(world.get_location("Hourglass Cave - Hourglass Chest"),
|
||||
@@ -1927,4 +1927,4 @@ def set_er_location_rules(world: "TunicWorld") -> None:
|
||||
|
||||
# zip through the rubble to sneakily grab this chest, or just fight to it
|
||||
add_rule(world.get_location("Cathedral - [1F] Near Spikes"),
|
||||
lambda state: laurels_zip(state, world) or has_combat_reqs("Cathedral", state, player))
|
||||
lambda state: laurels_zip(state, world) or has_combat_reqs("Swamp", state, player))
|
||||
|
@@ -197,7 +197,6 @@ class TunicPlandoConnections(PlandoConnections):
|
||||
|
||||
class CombatLogic(Choice):
|
||||
"""
|
||||
EXPERIMENTAL - may cause gen failures, especially when playthrough generation for the spoiler log is enabled, and may have slight logic issues.
|
||||
If enabled, the player will logically require a combination of stat upgrade items and equipment to get some checks or navigate to some areas, with a goal of matching the vanilla combat difficulty.
|
||||
The player may still be expected to run past enemies, reset aggro (by using a checkpoint or doing a scene transition), or find sneaky paths to checks.
|
||||
This option marks many more items as progression and may force weapons much earlier than normal.
|
||||
|
83
worlds/tunic/test/test_combat.py
Normal file
83
worlds/tunic/test/test_combat.py
Normal file
@@ -0,0 +1,83 @@
|
||||
from BaseClasses import ItemClassification
|
||||
from collections import Counter
|
||||
|
||||
from . import TunicTestBase
|
||||
from .. import options
|
||||
from ..combat_logic import (check_combat_reqs, area_data, get_money_count, calc_effective_hp, get_potion_level,
|
||||
get_hp_level, get_def_level, get_sp_level)
|
||||
from ..items import item_table
|
||||
from .. import TunicWorld
|
||||
|
||||
|
||||
class TestCombat(TunicTestBase):
|
||||
options = {options.CombatLogic.internal_name: options.CombatLogic.option_on}
|
||||
player = 1
|
||||
world: TunicWorld
|
||||
combat_items = []
|
||||
# these are items that are progression that do not contribute to combat logic
|
||||
# it's listed as using skipped items instead of a list of viable items so that if we add/remove some later,
|
||||
# that this won't require updates most likely
|
||||
# Stick and Sword are in here because sword progression is the clear determining case here
|
||||
skipped_items = {"Fairy", "Stick", "Sword", "Magic Dagger", "Magic Orb", "Lantern", "Old House Key", "Key",
|
||||
"Fortress Vault Key", "Golden Coin", "Red Questagon", "Green Questagon", "Blue Questagon",
|
||||
"Scavenger Mask", "Pages 24-25 (Prayer)", "Pages 42-43 (Holy Cross)", "Pages 52-53 (Icebolt)"}
|
||||
# converts golden trophies to their hero relic stat equivalent, for easier parsing
|
||||
converter = {
|
||||
"Secret Legend": "Hero Relic - DEF",
|
||||
"Phonomath": "Hero Relic - DEF",
|
||||
"Just Some Pals": "Hero Relic - POTION",
|
||||
"Spring Falls": "Hero Relic - POTION",
|
||||
"Back To Work": "Hero Relic - POTION",
|
||||
"Mr Mayor": "Hero Relic - SP",
|
||||
"Power Up": "Hero Relic - SP",
|
||||
"Regal Weasel": "Hero Relic - SP",
|
||||
"Forever Friend": "Hero Relic - SP",
|
||||
"Sacred Geometry": "Hero Relic - MP",
|
||||
"Vintage": "Hero Relic - MP",
|
||||
"Dusty": "Hero Relic - MP",
|
||||
}
|
||||
skipped_items.update({item for item in item_table.keys() if item.startswith("Ladder")})
|
||||
for item, data in item_table.items():
|
||||
if item in skipped_items:
|
||||
continue
|
||||
ic = data.combat_ic or data.classification
|
||||
if item in converter:
|
||||
item = converter[item]
|
||||
if ItemClassification.progression in ic:
|
||||
combat_items += [item] * data.quantity_in_item_pool
|
||||
|
||||
# we had an issue where collecting certain items brought certain areas out of logic
|
||||
# due to the weirdness of swapping between "you have enough attack that you don't need magic"
|
||||
# so this will make sure collecting an item doesn't bring something out of logic
|
||||
def test_combat_doesnt_fail_backwards(self):
|
||||
combat_items = self.combat_items.copy()
|
||||
self.multiworld.worlds[1].random.shuffle(combat_items)
|
||||
curr_statuses = {name: False for name in area_data.keys()}
|
||||
prev_statuses = curr_statuses.copy()
|
||||
area_names = list(area_data.keys())
|
||||
current_items = Counter()
|
||||
for current_item_name in combat_items:
|
||||
current_items[current_item_name] += 1
|
||||
current_item = TunicWorld.create_item(self.world, current_item_name)
|
||||
self.collect(current_item)
|
||||
self.multiworld.worlds[1].random.shuffle(area_names)
|
||||
for area in area_names:
|
||||
curr_statuses[area] = check_combat_reqs(area, self.multiworld.state, self.player)
|
||||
if curr_statuses[area] < prev_statuses[area]:
|
||||
data = area_data[area]
|
||||
state = self.multiworld.state
|
||||
player = self.player
|
||||
req_effective_hp = calc_effective_hp(data.hp_level, data.potion_level, data.potion_count)
|
||||
player_potion, potion_offerings = get_potion_level(state, player)
|
||||
player_hp, hp_offerings = get_hp_level(state, player)
|
||||
player_def, def_offerings = get_def_level(state, player)
|
||||
player_sp, sp_offerings = get_sp_level(state, player)
|
||||
raise Exception(f"Status for {area} decreased after collecting {current_item_name}.\n"
|
||||
f"Current items: {current_items}.\n"
|
||||
f"Total money: {get_money_count(self.multiworld.state, self.player)}.\n"
|
||||
f"Required Effective HP: {req_effective_hp}.\n"
|
||||
f"Free HP and Offerings: {player_hp - hp_offerings}, {hp_offerings}\n"
|
||||
f"Free Potion and Offerings: {player_potion - potion_offerings}, {potion_offerings}\n"
|
||||
f"Free Def and Offerings: {player_def - def_offerings}, {def_offerings}\n"
|
||||
f"Free SP and Offerings: {player_sp - sp_offerings}, {sp_offerings}")
|
||||
prev_statuses[area] = curr_statuses[area]
|
Reference in New Issue
Block a user