From 00b48140adece5b41a3abfcc8308a3f91fb7a6b6 Mon Sep 17 00:00:00 2001 From: Scipio Wright Date: Fri, 31 Jan 2025 12:12:58 -0500 Subject: [PATCH 01/18] Potential fix for attack issue --- worlds/tunic/combat_logic.py | 40 ++++++++++++++++++++++++++---------- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/worlds/tunic/combat_logic.py b/worlds/tunic/combat_logic.py index 9ff363942c9e..45bb09f4961f 100644 --- a/worlds/tunic/combat_logic.py +++ b/worlds/tunic/combat_logic.py @@ -204,6 +204,8 @@ def check_combat_reqs(area_name: str, state: CollectionState, player: int, alt_d def has_required_stats(data: AreaStats, state: CollectionState, player: int) -> bool: money_required = 0 player_att = 0 + # storing this in case we need it on the "a lot of att so no mp" step + money_req_for_att = 0 # check if we actually need the stat before checking state if data.att_level > 1: @@ -216,8 +218,10 @@ def has_required_stats(data: AreaStats, state: CollectionState, player: int) -> # attack upgrades cost 100 for the first, +50 for each additional money_per_att = 100 for _ in range(paid_att): + money_req_for_att += money_per_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: @@ -257,18 +261,32 @@ def has_required_stats(data: AreaStats, state: CollectionState, player: int) -> 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 + if data.mp_level > 1: + if player_att == 0: + player_att, att_offerings = get_att_level(state, player) + # data.att_level gets set to a negative value if we're going with no melee + if player_att >= data.att_level + 2 and data.att_level > 0: + # rolling this back so we can recalculate att money + money_required -= money_req_for_att + extra_att = player_att - (data.att_level + 2) + 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 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 + 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) player_potion, potion_offerings = get_potion_level(state, player) From dcaf84a9a819fb6f5d4547aa924dfc46a914853a Mon Sep 17 00:00:00 2001 From: Scipio Wright Date: Fri, 31 Jan 2025 12:15:28 -0500 Subject: [PATCH 02/18] also put the lazy version of the swamp fix in for good measure --- worlds/tunic/combat_logic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/worlds/tunic/combat_logic.py b/worlds/tunic/combat_logic.py index 45bb09f4961f..0b5143eca8b8 100644 --- a/worlds/tunic/combat_logic.py +++ b/worlds/tunic/combat_logic.py @@ -50,7 +50,7 @@ class AreaStats(NamedTuple): # these are used for caching which areas can currently be reached in state 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] +non_boss_areas: List[str] = [name for name, data in area_data.items() if not data.is_boss and name != "Swamp" and name != "Cathedral"] class CombatState(IntEnum): From 3915cb55617db40b217a1e6fa646da420175bbf8 Mon Sep 17 00:00:00 2001 From: Scipio Wright Date: Fri, 31 Jan 2025 20:05:29 -0500 Subject: [PATCH 03/18] fix extra line --- worlds/tunic/combat_logic.py | 1 - 1 file changed, 1 deletion(-) diff --git a/worlds/tunic/combat_logic.py b/worlds/tunic/combat_logic.py index 0b5143eca8b8..0b09991c6126 100644 --- a/worlds/tunic/combat_logic.py +++ b/worlds/tunic/combat_logic.py @@ -221,7 +221,6 @@ def has_required_stats(data: AreaStats, state: CollectionState, player: int) -> money_req_for_att += money_per_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: From 7201453408c08b30bd39f6a0c9344c0de9a8e8b4 Mon Sep 17 00:00:00 2001 From: Scipio Wright Date: Fri, 31 Jan 2025 20:40:49 -0500 Subject: [PATCH 04/18] now it is good --- worlds/tunic/combat_logic.py | 76 ++++++++++++++---------------------- 1 file changed, 30 insertions(+), 46 deletions(-) diff --git a/worlds/tunic/combat_logic.py b/worlds/tunic/combat_logic.py index 0b09991c6126..14693bd8fa8b 100644 --- a/worlds/tunic/combat_logic.py +++ b/worlds/tunic/combat_logic.py @@ -114,7 +114,7 @@ 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) + has_magic = state.has_any(("Magic Wand", "Gun"), player) stick_bool = False sword_bool = False for item in data.equipment: @@ -203,24 +203,36 @@ 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 - # storing this in case we need it on the "a lot of att so no mp" step - money_req_for_att = 0 - - # 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 + att_required = data.att_level + player_att, att_offerings = get_att_level(state, player) + + # 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_req_for_att += money_per_att - money_required += money_per_att - money_per_att += 50 + att_required += 2 + + if player_att < data.att_level: + return False + 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 # adding defense and sp together since they accomplish similar things: making you take less damage if data.def_level + data.sp_level > 2: @@ -259,34 +271,6 @@ def has_required_stats(data: AreaStats, state: CollectionState, player: int) -> 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: - if player_att == 0: - player_att, att_offerings = get_att_level(state, player) - # data.att_level gets set to a negative value if we're going with no melee - if player_att >= data.att_level + 2 and data.att_level > 0: - # rolling this back so we can recalculate att money - money_required -= money_req_for_att - extra_att = player_att - (data.att_level + 2) - 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 - else: - 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) player_potion, potion_offerings = get_potion_level(state, player) player_hp, hp_offerings = get_hp_level(state, player) From db001531a70f7ac76a8d2c6a7e93e6164f6ced5f Mon Sep 17 00:00:00 2001 From: Scipio Wright Date: Sat, 1 Feb 2025 12:01:57 -0500 Subject: [PATCH 05/18] Add the test, roll the other PR into this one --- worlds/tunic/combat_logic.py | 7 ++-- worlds/tunic/er_rules.py | 8 ++--- worlds/tunic/test/test_combat.py | 55 ++++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 6 deletions(-) create mode 100644 worlds/tunic/test/test_combat.py diff --git a/worlds/tunic/combat_logic.py b/worlds/tunic/combat_logic.py index 14693bd8fa8b..b8afff2edf5f 100644 --- a/worlds/tunic/combat_logic.py +++ b/worlds/tunic/combat_logic.py @@ -41,7 +41,7 @@ class AreaStats(NamedTuple): "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 +49,10 @@ class AreaStats(NamedTuple): # 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 and name != "Swamp" and name != "Cathedral"] +# 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 +91,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: diff --git a/worlds/tunic/er_rules.py b/worlds/tunic/er_rules.py index 08b088f7e4a7..61d2c8c67277 100644 --- a/worlds/tunic/er_rules.py +++ b/worlds/tunic/er_rules.py @@ -1467,12 +1467,12 @@ def ls_connect(origin_name: str, portal_sdt: str) -> None: 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: @@ -1927,4 +1927,4 @@ def combat_logic_to_loc(loc_name: str, combat_req_area: str, set_instead: bool = # 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)) diff --git a/worlds/tunic/test/test_combat.py b/worlds/tunic/test/test_combat.py new file mode 100644 index 000000000000..f6fe66697896 --- /dev/null +++ b/worlds/tunic/test/test_combat.py @@ -0,0 +1,55 @@ +from BaseClasses import ItemClassification +from random import Random + +from . import TunicTestBase +from .. import options +from ..combat_logic import check_combat_reqs, area_data +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 = [] + 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)"} + 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 ItemClassification.progression in ic: + for _ in range(data.quantity_in_item_pool): + combat_items.append(item) + + # 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): + random_obj = Random() + combat_items = self.combat_items.copy() + random_obj.shuffle(combat_items) + curr_statuses = {name: False for name in area_data.keys()} + prev_statuses = curr_statuses.copy() + area_names = [name for name in area_data.keys()] + + while len(combat_items): + current_item_name = combat_items.pop() + current_item = TunicWorld.create_item(self.world, current_item_name) + self.collect(current_item) + random_obj.shuffle(area_names) + for area in area_names: + curr_statuses[area] = check_combat_reqs(area, self.multiworld.state, self.player) + can_stop = True + for area, status in curr_statuses.items(): + if status < prev_statuses[area]: + raise Exception(f"Status for {area} decreased after collecting {current_item_name}.") + if not status: + can_stop = False + prev_statuses[area] = status + # if they're all True, we're probably not regressing anymore so let's save some time and stop now + if can_stop: + break From 978f28e53d77521e5c76f62b312c42c5e5da8064 Mon Sep 17 00:00:00 2001 From: Scipio Wright Date: Sat, 1 Feb 2025 22:17:01 -0500 Subject: [PATCH 06/18] Make the test exception more useful --- worlds/tunic/test/test_combat.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/worlds/tunic/test/test_combat.py b/worlds/tunic/test/test_combat.py index f6fe66697896..db431c824e03 100644 --- a/worlds/tunic/test/test_combat.py +++ b/worlds/tunic/test/test_combat.py @@ -1,4 +1,5 @@ from BaseClasses import ItemClassification +from collections import Counter from random import Random from . import TunicTestBase @@ -35,21 +36,18 @@ def test_combat_doesnt_fail_backwards(self): curr_statuses = {name: False for name in area_data.keys()} prev_statuses = curr_statuses.copy() area_names = [name for name in area_data.keys()] - + current_items = Counter({name: 0 for name in combat_items}) while len(combat_items): current_item_name = combat_items.pop() + current_items[current_item_name] += 1 current_item = TunicWorld.create_item(self.world, current_item_name) self.collect(current_item) random_obj.shuffle(area_names) for area in area_names: curr_statuses[area] = check_combat_reqs(area, self.multiworld.state, self.player) - can_stop = True for area, status in curr_statuses.items(): if status < prev_statuses[area]: - raise Exception(f"Status for {area} decreased after collecting {current_item_name}.") - if not status: - can_stop = False + raise Exception(f"Status for {area} decreased after collecting {current_item_name}.\n" + f"Current items: {current_items}") + print(current_items) prev_statuses[area] = status - # if they're all True, we're probably not regressing anymore so let's save some time and stop now - if can_stop: - break From c7af1507a4cf16e0221448d4ecfee2cf17897cb9 Mon Sep 17 00:00:00 2001 From: Scipio Wright Date: Sun, 2 Feb 2025 00:01:20 -0500 Subject: [PATCH 07/18] Remove debug print --- worlds/tunic/test/test_combat.py | 1 - 1 file changed, 1 deletion(-) diff --git a/worlds/tunic/test/test_combat.py b/worlds/tunic/test/test_combat.py index db431c824e03..75c1ac914396 100644 --- a/worlds/tunic/test/test_combat.py +++ b/worlds/tunic/test/test_combat.py @@ -49,5 +49,4 @@ def test_combat_doesnt_fail_backwards(self): if status < prev_statuses[area]: raise Exception(f"Status for {area} decreased after collecting {current_item_name}.\n" f"Current items: {current_items}") - print(current_items) prev_statuses[area] = status From 004eb9d7f430d20425be26dda167f7c1aaa2255b Mon Sep 17 00:00:00 2001 From: Scipio Wright Date: Sun, 2 Feb 2025 13:54:15 -0500 Subject: [PATCH 08/18] Combat logic fixed? --- worlds/tunic/combat_logic.py | 260 ++++++++++++++++++------------- worlds/tunic/test/test_combat.py | 47 +++++- 2 files changed, 195 insertions(+), 112 deletions(-) diff --git a/worlds/tunic/combat_logic.py b/worlds/tunic/combat_logic.py index b8afff2edf5f..f312621941cb 100644 --- a/worlds/tunic/combat_logic.py +++ b/worlds/tunic/combat_logic.py @@ -8,6 +8,7 @@ # 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 @@ -118,87 +119,97 @@ def check_combat_reqs(area_name: str, state: CollectionState, player: int, alt_d extra_def_needed = 0 extra_mp_needed = 0 has_magic = state.has_any(("Magic Wand", "Gun"), player) - stick_bool = False - sword_bool = False + stick_bool = has_melee(state, player) + sword_bool = has_sword(state, player) + equipment = data.equipment.copy() for item in data.equipment: if item == "Stick": - if not has_melee(state, player): + if not stick_bool: + equipment.remove("Stick") if has_magic: + 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 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 @@ -229,7 +240,7 @@ def has_required_stats(data: AreaStats, state: CollectionState, player: int) -> if player_att < data.att_level: return False else: - extra_att = player_att - data.att_level + 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 @@ -241,38 +252,36 @@ def has_required_stats(data: AreaStats, state: CollectionState, player: int) -> 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 + # 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 i in range(def_offerings): + paid_def = i + 1 + if paid_def >= stats_to_buy: + upgrade_options.add((paid_def, 0)) + for j in range(sp_offerings): + paid_sp = j + 1 + if paid_def + paid_sp >= stats_to_buy: + upgrade_options.add((paid_def, paid_sp)) + for i in range(sp_offerings): + paid_sp = i + 1 + if paid_sp >= stats_to_buy: + upgrade_options.add((0, paid_sp)) + costs = [] + for defense, sp in upgrade_options: + costs.append(calc_def_sp_cost(defense, sp)) + 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) @@ -283,53 +292,36 @@ 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: + # 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() 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 + if calc_effective_hp(free_hp, free_potion + paid_potion_count, player_potion_count) >= req_effective_hp: + upgrade_options.add((0, paid_potion_count)) 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): + >= req_effective_hp): + upgrade_options.add((paid_hp_count, paid_potion_count)) 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 - - if money_required > get_money_count(state, player): - return False + for i in range(hp_offerings): + paid_hp_count = i + 1 + if calc_effective_hp(free_hp + paid_hp_count, free_potion, player_potion_count) >= req_effective_hp: + upgrade_options.add((paid_hp_count, 0)) + break + costs = [] + for hp, potion in upgrade_options: + costs.append(calc_hp_potion_cost(hp, potion)) + money_required += min(costs) - return True + return get_money_count(state, player) >= money_required # returns a tuple of your max attack level, the number of attack offerings @@ -340,7 +332,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 @@ -348,7 +341,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) @@ -412,6 +407,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] @@ -424,3 +459,14 @@ def init_mixin(self, _): 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)) + + def copy_mixin(self, new_state: CollectionState) -> CollectionState: + # the per-player need to reset the combat state when collecting a combat item + new_state.tunic_need_to_reset_combat_from_collect = defaultdict(lambda: False) + # the per-player need to reset the combat state when removing a combat item + new_state.tunic_need_to_reset_combat_from_remove = defaultdict(lambda: False) + # the per-player, per-area state of combat checking -- unchecked, failed, or succeeded + new_state.tunic_area_combat_state = defaultdict(lambda: defaultdict(lambda: CombatState.unchecked)) + + return new_state + diff --git a/worlds/tunic/test/test_combat.py b/worlds/tunic/test/test_combat.py index 75c1ac914396..9fa3783ba1a8 100644 --- a/worlds/tunic/test/test_combat.py +++ b/worlds/tunic/test/test_combat.py @@ -4,7 +4,8 @@ from . import TunicTestBase from .. import options -from ..combat_logic import check_combat_reqs, area_data +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 @@ -14,9 +15,28 @@ class TestCombat(TunicTestBase): 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: @@ -24,6 +44,8 @@ class TestCombat(TunicTestBase): ic = data.combat_ic or data.classification if ItemClassification.progression in ic: for _ in range(data.quantity_in_item_pool): + if item in converter: + item = converter[item] combat_items.append(item) # we had an issue where collecting certain items brought certain areas out of logic @@ -37,16 +59,31 @@ def test_combat_doesnt_fail_backwards(self): prev_statuses = curr_statuses.copy() area_names = [name for name in area_data.keys()] current_items = Counter({name: 0 for name in combat_items}) + collected_items = [] while len(combat_items): current_item_name = combat_items.pop() current_items[current_item_name] += 1 current_item = TunicWorld.create_item(self.world, current_item_name) self.collect(current_item) + collected_items.append(current_item) random_obj.shuffle(area_names) for area in area_names: curr_statuses[area] = check_combat_reqs(area, self.multiworld.state, self.player) - for area, status in curr_statuses.items(): - if status < prev_statuses[area]: + 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}") - prev_statuses[area] = status + 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] From 4c42804abd0c7efed4e76c2d302cc7beb472f996 Mon Sep 17 00:00:00 2001 From: Scipio Wright Date: Sun, 2 Feb 2025 20:25:30 -0500 Subject: [PATCH 09/18] Move a few areas to before well instead of east forest --- worlds/tunic/er_rules.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/worlds/tunic/er_rules.py b/worlds/tunic/er_rules.py index 61d2c8c67277..4d0a462cbb8a 100644 --- a/worlds/tunic/er_rules.py +++ b/worlds/tunic/er_rules.py @@ -1386,9 +1386,9 @@ def ls_connect(origin_name: str, portal_sdt: str) -> None: # 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)) @@ -1835,10 +1835,10 @@ def combat_logic_to_loc(loc_name: str, combat_req_area: str, set_instead: bool = 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"), From fe2d5cdeeb8b3c1fe4d61d864ba358c155af7bb0 Mon Sep 17 00:00:00 2001 From: Scipio Wright Date: Mon, 3 Feb 2025 10:08:43 -0500 Subject: [PATCH 10/18] Put in qwint's suggestions in test --- worlds/tunic/test/test_combat.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/worlds/tunic/test/test_combat.py b/worlds/tunic/test/test_combat.py index 9fa3783ba1a8..24bca96b54a2 100644 --- a/worlds/tunic/test/test_combat.py +++ b/worlds/tunic/test/test_combat.py @@ -42,11 +42,10 @@ class TestCombat(TunicTestBase): 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: - for _ in range(data.quantity_in_item_pool): - if item in converter: - item = converter[item] - combat_items.append(item) + 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" @@ -57,11 +56,10 @@ def test_combat_doesnt_fail_backwards(self): random_obj.shuffle(combat_items) curr_statuses = {name: False for name in area_data.keys()} prev_statuses = curr_statuses.copy() - area_names = [name for name in area_data.keys()] - current_items = Counter({name: 0 for name in combat_items}) + area_names = list(area_data.keys()) + current_items = Counter() collected_items = [] - while len(combat_items): - current_item_name = combat_items.pop() + 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) From 43c22a10ba120fedfbf5fea95ba0af5513c92bfd Mon Sep 17 00:00:00 2001 From: Scipio Wright Date: Mon, 3 Feb 2025 10:15:10 -0500 Subject: [PATCH 11/18] Implement qwint's suggestions in combat_logic.py --- worlds/tunic/combat_logic.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/worlds/tunic/combat_logic.py b/worlds/tunic/combat_logic.py index f312621941cb..85bb21096810 100644 --- a/worlds/tunic/combat_logic.py +++ b/worlds/tunic/combat_logic.py @@ -119,14 +119,14 @@ def check_combat_reqs(area_name: str, state: CollectionState, player: int, alt_d extra_def_needed = 0 extra_mp_needed = 0 has_magic = state.has_any(("Magic Wand", "Gun"), player) - stick_bool = has_melee(state, 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 stick_bool: - equipment.remove("Stick") if has_magic: + equipment.remove("Stick") if "Magic" not in equipment: equipment.append("Magic") # magic can make up for the lack of stick @@ -161,6 +161,7 @@ def check_combat_reqs(area_name: str, state: CollectionState, player: int, alt_d else: return False + # just increase the stat requirement, we'll check for shield when calculating defense elif item == "Shield": equipment.remove("Shield") extra_def_needed += 2 @@ -237,7 +238,7 @@ def has_required_stats(data: AreaStats, state: CollectionState, player: int) -> else: att_required += 2 - if player_att < data.att_level: + if player_att < att_required: return False else: extra_att = player_att - att_required @@ -278,9 +279,7 @@ def has_required_stats(data: AreaStats, state: CollectionState, player: int) -> paid_sp = i + 1 if paid_sp >= stats_to_buy: upgrade_options.add((0, paid_sp)) - costs = [] - for defense, sp in upgrade_options: - costs.append(calc_def_sp_cost(defense, sp)) + 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) @@ -316,9 +315,7 @@ def has_required_stats(data: AreaStats, state: CollectionState, player: int) -> if calc_effective_hp(free_hp + paid_hp_count, free_potion, player_potion_count) >= req_effective_hp: upgrade_options.add((paid_hp_count, 0)) break - costs = [] - for hp, potion in upgrade_options: - costs.append(calc_hp_potion_cost(hp, potion)) + costs = [calc_hp_potion_cost(hp, potion) for hp, potion in upgrade_options] money_required += min(costs) return get_money_count(state, player) >= money_required From 7adb1a86cfbaa558141fedce2b700f799116c338 Mon Sep 17 00:00:00 2001 From: Scipio Wright Date: Mon, 3 Feb 2025 10:29:50 -0500 Subject: [PATCH 12/18] Implement qwint's suggestions for combat_logic.py --- worlds/tunic/combat_logic.py | 47 ++++++++++++++++++------------------ 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/worlds/tunic/combat_logic.py b/worlds/tunic/combat_logic.py index 85bb21096810..4fc53eebd7dd 100644 --- a/worlds/tunic/combat_logic.py +++ b/worlds/tunic/combat_logic.py @@ -267,18 +267,16 @@ def has_required_stats(data: AreaStats, state: CollectionState, player: int) -> # 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 i in range(def_offerings): - paid_def = i + 1 - if paid_def >= stats_to_buy: - upgrade_options.add((paid_def, 0)) - for j in range(sp_offerings): - paid_sp = j + 1 - if paid_def + paid_sp >= stats_to_buy: - upgrade_options.add((paid_def, paid_sp)) - for i in range(sp_offerings): - paid_sp = i + 1 - if paid_sp >= stats_to_buy: - upgrade_options.add((0, paid_sp)) + # check if we can get it with just defense offerings + if def_offerings >= stats_to_buy: + upgrade_options.add((stats_to_buy, 0)) + # check if we can get it with just sp offerings + if sp_offerings >= stats_to_buy: + upgrade_options.add((0, stats_to_buy)) + # check how many sp offerings we need for each defense offering + for paid_def in range(1, def_offerings + 1): + if sp_offerings + paid_def >= stats_to_buy: + 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) @@ -300,20 +298,23 @@ def has_required_stats(data: AreaStats, state: CollectionState, player: int) -> # 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() - 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: + got_0_hp_case = False + for paid_potion in range(1, potion_offerings + 1): + # todo: fix line length when in an IDE + # check the 0 hp offerings case + if not got_0_hp_case and calc_effective_hp(free_hp, free_potion + paid_potion, player_potion_count) >= req_effective_hp: upgrade_options.add((0, paid_potion_count)) - 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) + got_0_hp_case = True + # check quantities of hp offerings for each potion offering + for paid_hp in range(1, hp_offerings + 1): + if (calc_effective_hp(free_hp + paid_hp, free_potion + paid_potion, player_potion_count) >= req_effective_hp): - upgrade_options.add((paid_hp_count, paid_potion_count)) + upgrade_options.add((paid_hp, paid_potion)) break - for i in range(hp_offerings): - paid_hp_count = i + 1 - if calc_effective_hp(free_hp + paid_hp_count, free_potion, player_potion_count) >= req_effective_hp: - upgrade_options.add((paid_hp_count, 0)) + # check the 0 potion offerings case + for paid_hp in range(1, hp_offerings + 1): + if calc_effective_hp(free_hp + paid_hp, free_potion, player_potion_count) >= req_effective_hp: + upgrade_options.add((paid_hp, 0)) break costs = [calc_hp_potion_cost(hp, potion) for hp, potion in upgrade_options] money_required += min(costs) From 361ba0ce26f9f623be3a38ce95ca4556d898825d Mon Sep 17 00:00:00 2001 From: Scipio Wright Date: Mon, 3 Feb 2025 10:31:56 -0500 Subject: [PATCH 13/18] Fix typo --- worlds/tunic/combat_logic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/worlds/tunic/combat_logic.py b/worlds/tunic/combat_logic.py index 4fc53eebd7dd..fe5deb6d4b1f 100644 --- a/worlds/tunic/combat_logic.py +++ b/worlds/tunic/combat_logic.py @@ -303,7 +303,7 @@ def has_required_stats(data: AreaStats, state: CollectionState, player: int) -> # todo: fix line length when in an IDE # check the 0 hp offerings case if not got_0_hp_case and calc_effective_hp(free_hp, free_potion + paid_potion, player_potion_count) >= req_effective_hp: - upgrade_options.add((0, paid_potion_count)) + upgrade_options.add((0, paid_potion)) got_0_hp_case = True # check quantities of hp offerings for each potion offering for paid_hp in range(1, hp_offerings + 1): From eae61326e9844893bef4cf1c13c7dcea044100a6 Mon Sep 17 00:00:00 2001 From: Scipio Wright Date: Mon, 3 Feb 2025 10:32:48 -0500 Subject: [PATCH 14/18] Remove experimental from combat logic description --- worlds/tunic/options.py | 1 - 1 file changed, 1 deletion(-) diff --git a/worlds/tunic/options.py b/worlds/tunic/options.py index 8fe2ea5ce854..d2ea82803704 100644 --- a/worlds/tunic/options.py +++ b/worlds/tunic/options.py @@ -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. From 8982470941f673cc6735ddca755f8dc5ded6fa48 Mon Sep 17 00:00:00 2001 From: Scipio Wright Date: Mon, 3 Feb 2025 10:52:46 -0500 Subject: [PATCH 15/18] Remove copy_mixin again --- worlds/tunic/combat_logic.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/worlds/tunic/combat_logic.py b/worlds/tunic/combat_logic.py index fe5deb6d4b1f..cc831a12f522 100644 --- a/worlds/tunic/combat_logic.py +++ b/worlds/tunic/combat_logic.py @@ -457,14 +457,3 @@ def init_mixin(self, _): 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)) - - def copy_mixin(self, new_state: CollectionState) -> CollectionState: - # the per-player need to reset the combat state when collecting a combat item - new_state.tunic_need_to_reset_combat_from_collect = defaultdict(lambda: False) - # the per-player need to reset the combat state when removing a combat item - new_state.tunic_need_to_reset_combat_from_remove = defaultdict(lambda: False) - # the per-player, per-area state of combat checking -- unchecked, failed, or succeeded - new_state.tunic_area_combat_state = defaultdict(lambda: defaultdict(lambda: CombatState.unchecked)) - - return new_state - From 1447fe4355773c6f14d14fb55daf5eb936200367 Mon Sep 17 00:00:00 2001 From: Scipio Wright Date: Mon, 3 Feb 2025 10:59:27 -0500 Subject: [PATCH 16/18] Add comment about copy_mixin --- worlds/tunic/combat_logic.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/worlds/tunic/combat_logic.py b/worlds/tunic/combat_logic.py index cc831a12f522..ae0179694710 100644 --- a/worlds/tunic/combat_logic.py +++ b/worlds/tunic/combat_logic.py @@ -457,3 +457,5 @@ def init_mixin(self, _): 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 From 2c3ef63d5e87b4014c2fd95178b96aa05d1ac1e1 Mon Sep 17 00:00:00 2001 From: Scipio Wright Date: Sun, 9 Feb 2025 09:02:33 -0500 Subject: [PATCH 17/18] Use a more proper random --- worlds/tunic/test/test_combat.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/worlds/tunic/test/test_combat.py b/worlds/tunic/test/test_combat.py index 24bca96b54a2..4d0f6c87865f 100644 --- a/worlds/tunic/test/test_combat.py +++ b/worlds/tunic/test/test_combat.py @@ -1,6 +1,5 @@ from BaseClasses import ItemClassification from collections import Counter -from random import Random from . import TunicTestBase from .. import options @@ -51,9 +50,8 @@ class TestCombat(TunicTestBase): # 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): - random_obj = Random() combat_items = self.combat_items.copy() - random_obj.shuffle(combat_items) + 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()) @@ -64,7 +62,7 @@ def test_combat_doesnt_fail_backwards(self): current_item = TunicWorld.create_item(self.world, current_item_name) self.collect(current_item) collected_items.append(current_item) - random_obj.shuffle(area_names) + 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]: From b05f8e1dc23c0697879ccc4704935191a315d2ba Mon Sep 17 00:00:00 2001 From: Scipio Wright Date: Sun, 9 Feb 2025 10:36:24 -0500 Subject: [PATCH 18/18] Some optimizations from Vi's comments --- worlds/tunic/combat_logic.py | 33 +++++++++++--------------------- worlds/tunic/test/test_combat.py | 2 -- 2 files changed, 11 insertions(+), 24 deletions(-) diff --git a/worlds/tunic/combat_logic.py b/worlds/tunic/combat_logic.py index ae0179694710..2e490d1dad6e 100644 --- a/worlds/tunic/combat_logic.py +++ b/worlds/tunic/combat_logic.py @@ -267,15 +267,11 @@ def has_required_stats(data: AreaStats, state: CollectionState, player: int) -> # first number is def, second number is sp upgrade_options: set[tuple[int, int]] = set() stats_to_buy = req_stats - free_def - free_sp - # check if we can get it with just defense offerings - if def_offerings >= stats_to_buy: - upgrade_options.add((stats_to_buy, 0)) - # check if we can get it with just sp offerings - if sp_offerings >= stats_to_buy: - upgrade_options.add((0, stats_to_buy)) - # check how many sp offerings we need for each defense offering - for paid_def in range(1, def_offerings + 1): - if sp_offerings + paid_def >= stats_to_buy: + 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) @@ -298,24 +294,17 @@ def has_required_stats(data: AreaStats, state: CollectionState, player: int) -> # 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() - got_0_hp_case = False - for paid_potion in range(1, potion_offerings + 1): - # todo: fix line length when in an IDE - # check the 0 hp offerings case - if not got_0_hp_case and calc_effective_hp(free_hp, free_potion + paid_potion, player_potion_count) >= req_effective_hp: - upgrade_options.add((0, paid_potion)) - got_0_hp_case = True + # 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(1, hp_offerings + 1): + 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 - # check the 0 potion offerings case - for paid_hp in range(1, hp_offerings + 1): - if calc_effective_hp(free_hp + paid_hp, free_potion, player_potion_count) >= req_effective_hp: - upgrade_options.add((paid_hp, 0)) - break + costs = [calc_hp_potion_cost(hp, potion) for hp, potion in upgrade_options] money_required += min(costs) diff --git a/worlds/tunic/test/test_combat.py b/worlds/tunic/test/test_combat.py index 4d0f6c87865f..866dc5f81429 100644 --- a/worlds/tunic/test/test_combat.py +++ b/worlds/tunic/test/test_combat.py @@ -56,12 +56,10 @@ def test_combat_doesnt_fail_backwards(self): prev_statuses = curr_statuses.copy() area_names = list(area_data.keys()) current_items = Counter() - collected_items = [] 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) - collected_items.append(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)