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:
Scipio Wright
2025-02-09 13:12:17 -05:00
committed by GitHub
parent f5c574c37a
commit 359f45d50f
4 changed files with 261 additions and 151 deletions

View File

@@ -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 # the vanilla stats you are expected to have to get through an area, based on where they are in vanilla
class AreaStats(NamedTuple): class AreaStats(NamedTuple):
"""Attack, Defense, Potion, HP, SP, MP, Flasks, Equipment, is_boss"""
att_level: int att_level: int
def_level: int def_level: int
potion_level: int # all 3 are before your first bonfire after getting the upgrade page, third costs 1k 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"]), "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), "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"]), "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 # 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), "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), "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 # 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"] 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): 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: elif area_name in non_boss_areas:
area_list = non_boss_areas area_list = non_boss_areas
else: else:
# this is to check Swamp and Gauntlet on their own
area_list = [area_name] area_list = [area_name]
if met_combat_reqs: 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_att_needed = 0
extra_def_needed = 0 extra_def_needed = 0
extra_mp_needed = 0 extra_mp_needed = 0
has_magic = state.has_any({"Magic Wand", "Gun"}, player) has_magic = state.has_any(("Magic Wand", "Gun"), player)
stick_bool = False sword_bool = has_sword(state, player)
sword_bool = False stick_bool = sword_bool or has_melee(state, player)
equipment = data.equipment.copy()
for item in data.equipment: for item in data.equipment:
if item == "Stick": if item == "Stick":
if not has_melee(state, player): if not stick_bool:
if has_magic: if has_magic:
equipment.remove("Stick")
if "Magic" not in equipment:
equipment.append("Magic")
# magic can make up for the lack of stick # magic can make up for the lack of stick
extra_mp_needed += 2 extra_mp_needed += 2
extra_att_needed -= 16 extra_att_needed -= 32
else: else:
return False return False
else:
stick_bool = True
elif item == "Sword": elif item == "Sword":
if not has_sword(state, player): if not sword_bool:
# need sword for bosses # need sword for bosses
if data.is_boss: if data.is_boss:
return False return False
equipment.remove("Sword")
if has_magic: 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 # +4 mp pretty much makes up for the lack of sword, at least in Quarry
extra_mp_needed += 4 extra_mp_needed += 4
# stick is a backup plan, and doesn't scale well, so let's require a little less if stick_bool:
extra_att_needed -= 2 # stick is a backup plan, and doesn't scale well, so let's require a little less
elif has_melee(state, player): 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 # may revise this later based on feedback
extra_att_needed += 3 extra_att_needed += 3
extra_def_needed += 2 extra_def_needed += 2
else: else:
return False return False
else:
sword_bool = True
# just increase the stat requirement, we'll check for shield when calculating defense
elif item == "Shield": elif item == "Shield":
if not state.has("Shield", player): equipment.remove("Shield")
extra_def_needed += 2 extra_def_needed += 2
elif item == "Laurels": elif item == "Laurels":
if not state.has("Hero's Laurels", player): if not state.has("Hero's Laurels", player):
# these are entirely based on vibes # require Laurels for the Heir
extra_att_needed += 2 return False
extra_def_needed += 3
elif item == "Magic": elif item == "Magic":
if not has_magic: if not has_magic:
equipment.remove("Magic")
extra_att_needed += 2 extra_att_needed += 2
extra_def_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, 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) data.hp_level, data.sp_level, data.mp_level + extra_mp_needed, data.potion_count,
if not has_required_stats(modified_stats, state, player): 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 # 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 equipment and has_magic:
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 the sword
# we need to check if you would have the required stats if you didn't have melee equip_list = [item for item in equipment if item != "Sword"]
equip_list = [item for item in data.equipment if item != "Sword"] if "Magic" not in equip_list:
more_modified_stats = AreaStats(data.att_level - 16, data.def_level, data.potion_level, equip_list.append("Magic")
data.hp_level, data.sp_level, data.mp_level + 4, data.potion_count, more_modified_stats = AreaStats(modified_stats.att_level - 32, modified_stats.def_level,
equip_list) 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): if check_combat_reqs("none", state, player, more_modified_stats):
return True return True
# and we need to check if you would have the required stats if you didn't have magic elif stick_bool and "Stick" in equipment and has_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:
# we need to check if you would have the required stats if you didn't have the stick # 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"] equip_list = [item for item in equipment if item != "Stick"]
more_modified_stats = AreaStats(data.att_level - 16, data.def_level, data.potion_level, if "Magic" not in equip_list:
data.hp_level, data.sp_level, data.mp_level + 4, data.potion_count, equip_list.append("Magic")
equip_list) 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): if check_combat_reqs("none", state, player, more_modified_stats):
return True return True
return False
else: else:
return False return False
return True return False
# check if you have the required stats, and the money to afford them # 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 # but that's fine -- it's already pretty generous to begin with
def has_required_stats(data: AreaStats, state: CollectionState, player: int) -> bool: def has_required_stats(data: AreaStats, state: CollectionState, player: int) -> bool:
money_required = 0 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 you have 2 more attack than needed, we can forego needing mp
if data.att_level > 1: if data.mp_level > 1:
player_att, att_offerings = get_att_level(state, player) if player_att < data.att_level + 2:
if player_att < data.att_level: player_mp, mp_offerings = get_mp_level(state, player)
return False 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: else:
extra_att = player_att - data.att_level att_required += 2
paid_att = max(0, att_offerings - extra_att)
# attack upgrades cost 100 for the first, +50 for each additional if player_att < att_required:
money_per_att = 100 return False
for _ in range(paid_att): else:
money_required += money_per_att extra_att = player_att - att_required
money_per_att += 50 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 # adding defense and sp together since they accomplish similar things: making you take less damage
if data.def_level + data.sp_level > 2: if data.def_level + data.sp_level > 2:
player_def, def_offerings = get_def_level(state, player) player_def, def_offerings = get_def_level(state, player)
player_sp, sp_offerings = get_sp_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 return False
else: else:
free_def = player_def - def_offerings free_def = player_def - def_offerings
free_sp = player_sp - sp_offerings free_sp = player_sp - sp_offerings
paid_stats = data.def_level + data.sp_level - free_def - free_sp if free_sp + free_def >= req_stats:
sp_to_buy = 0 # you don't need to buy upgrades
pass
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
else: else:
def_to_buy = def_offerings # we need to pick the cheapest option that gets us above the stats we need
sp_to_buy = max(0, paid_stats - def_offerings) # first number is def, second number is sp
upgrade_options: set[tuple[int, int]] = set()
# if you have to buy more than 3 def, it's cheaper to buy 1 extra sp stats_to_buy = req_stats - free_def - free_sp
if def_to_buy > 3 and sp_offerings > 0: for paid_def in range(0, min(def_offerings + 1, stats_to_buy + 1)):
def_to_buy -= 1 sp_required = stats_to_buy - paid_def
sp_to_buy += 1 if sp_offerings >= sp_required:
# def costs 100 for the first, +50 for each additional if sp_required < 0:
money_per_def = 100 break
for _ in range(def_to_buy): upgrade_options.add((paid_def, stats_to_buy - paid_def))
money_required += money_per_def costs = [calc_def_sp_cost(defense, sp) for defense, sp in upgrade_options]
money_per_def += 50 money_required += min(costs)
# 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
req_effective_hp = calc_effective_hp(data.hp_level, data.potion_level, data.potion_count) 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_potion, potion_offerings = get_potion_level(state, player)
@@ -279,53 +285,30 @@ def has_required_stats(data: AreaStats, state: CollectionState, player: int) ->
return False return False
else: else:
# need a way to determine which of potion offerings or hp offerings you can reduce # 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_potion = player_potion - potion_offerings
free_hp = player_hp - hp_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: if calc_effective_hp(free_hp, free_potion, player_potion_count) >= req_effective_hp:
# you don't need to buy upgrades # you don't need to buy upgrades
pass 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: else:
for i in range(potion_offerings): # we need to pick the cheapest option that gets us above the amount of effective HP we need
paid_potion_count = i + 1 # first number is hp, second number is potion
if calc_effective_hp(free_hp, free_potion + paid_potion_count, player_potion_count) > req_effective_hp: upgrade_options: set[tuple[int, int]] = set()
break # filter out exclusively worse options
for j in range(hp_offerings): lowest_hp_added = hp_offerings + 1
paid_hp_count = j + 1 for paid_potion in range(0, potion_offerings + 1):
if (calc_effective_hp(free_hp + paid_hp_count, free_potion + paid_potion_count, player_potion_count) # check quantities of hp offerings for each potion offering
> req_effective_hp): 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 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 costs = [calc_hp_potion_cost(hp, potion) for hp, potion in upgrade_options]
# currently we assume you will not buy past the second potion upgrade, but we might change our minds later money_required += min(costs)
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
if money_required > get_money_count(state, player): return get_money_count(state, player) >= money_required
return False
return True
# returns a tuple of your max attack level, the number of attack offerings # 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: if sword_level >= 3:
att_upgrades += min(2, sword_level - 2) att_upgrades += min(2, sword_level - 2)
# attack falls off, can just cap it at 8 for simplicity # 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 # 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) def_offerings = state.count("DEF Offering", player)
# defense falls off, can just cap it at 8 for simplicity # defense falls off, can just cap it at 8 for simplicity
return (min(8, 1 + def_offerings 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) def_offerings)
@@ -408,6 +394,46 @@ def get_money_count(state: CollectionState, player: int) -> int:
return money 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): class TunicState(LogicMixin):
tunic_need_to_reset_combat_from_collect: Dict[int, bool] tunic_need_to_reset_combat_from_collect: Dict[int, bool]
tunic_need_to_reset_combat_from_remove: 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) self.tunic_need_to_reset_combat_from_remove = defaultdict(lambda: False)
# the per-player, per-area state of combat checking -- unchecked, failed, or succeeded # the per-player, per-area state of combat checking -- unchecked, failed, or succeeded
self.tunic_area_combat_state = defaultdict(lambda: defaultdict(lambda: CombatState.unchecked)) 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

View File

@@ -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 # need to fight through the rudelings and turret, or just laurels from near the windmill
set_rule(ow_to_well_entry, set_rule(ow_to_well_entry,
lambda state: state.has(laurels, player) 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, 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, add_rule(atoll_statue,
lambda state: has_combat_reqs("Ruined Atoll", state, player)) 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, set_rule(cath_entry_to_elev,
lambda state: options.entrance_rando lambda state: options.entrance_rando
or has_ice_grapple_logic(False, IceGrappling.option_medium, state, world) 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, 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, 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 # for spots where you can go into and come out of an entrance to reset enemy aggro
if world.options.entrance_rando: 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 - [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", "Overworld", dagger=True)
combat_logic_to_loc("Overworld - [Southwest] West Beach Guarded By Turret 2", "Overworld") 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] Bombable Wall Near Fountain", "Before Well", dagger=True)
combat_logic_to_loc("Overworld - [Southwest] Fountain Holy Cross", "East Forest", 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", "East Forest", 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", "East Forest", 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") combat_logic_to_loc("Overworld - [Northwest] Chest Near Turret", "Before Well")
add_rule(world.get_location("Hourglass Cave - Hourglass Chest"), 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 # zip through the rubble to sneakily grab this chest, or just fight to it
add_rule(world.get_location("Cathedral - [1F] Near Spikes"), 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))

View File

@@ -197,7 +197,6 @@ class TunicPlandoConnections(PlandoConnections):
class CombatLogic(Choice): 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. 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. 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. This option marks many more items as progression and may force weapons much earlier than normal.

View 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]