Files
Grinch-AP/worlds/tunic/combat_logic.py

442 lines
20 KiB
Python
Raw Permalink Normal View History

from typing import Dict, List, NamedTuple, Tuple, Optional
from enum import IntEnum
from collections import defaultdict
from BaseClasses import CollectionState
from .rules import has_sword, has_melee
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
hp_level: int
sp_level: int
mp_level: int
potion_count: int
equipment: List[str] = []
is_boss: bool = False
# the vanilla upgrades/equipment you would have
area_data: Dict[str, AreaStats] = {
TUNIC: Entrance rando Direction Pairs + Decoupled (#3761) * Fix merge conflict * Fix formatting, fix rule for heir access after merge * Writing combat logic helpers * More helpers! * More logic! * Rename has_stick to has_melee, some fixes per Medic's review * Clamp max power from sword upgrades * Wrote the rest of the helpers * Remove unused import * Apply item classifications * Create the combat logic option * Item classification varies based on option * Add the shop sword logic stuff in * Add the rules for the boss-only option * Fix tiny issues * Some early Overworld combat logic * Fill out swamp combat logic * Add note * Bump up Boss Scav and Heir * More revisions to combat logic * Some changes, currently broken * New system for power, kinda jank probably * Revisions to new system, needs more balancing * Cap attack upgrades * Uncap mp power since it's directly related to damage output * Voidlings * Put together a table showing the vanilla-expected stats for each area * Added some info on potion counts * Made new helper functions * Make has_required_stats * Make has_combat_reqs * Update er_rules for new combat reqs * Fix all the broken things ever * Remove outdated todo * Make temp option for testing logic * More flexible choices for combat items * Hard require sword for bosses * Temporarily default combat logic to on * Finish writing overworld combat logic * East Forest combat logic done * Remove a few easy ones * Finish beneath the well * Dark Tomb combat logic * West Garden combat logic * make unit tests checkmark again * Weird west garden dagger house edge case * Try block for that weird west garden edge case * Add quarry combat logic * Update to filter out unreachable regions outside of ER * Fortress Grave Path logic, and a couple fixes to the west garden logic * Fortress east shortcut logic, and rewriting the try except blocks to use finally * Refactor to use a new function cause wow there was a lot of repeated code * Add combat logic to the other two sets of fortress fuses * Add combat rules to beneath the vault * Fix missing cathedral -> elevator connection * Combat logic for cathedral to elevator * Add cathedral main region, rename cathedral -> cathedral entry * Setup cathedral combat logic * Adjust locations' regions for ER * Add laurels zip logic to the chest in the spike room in cathedral * Add combat logic to frog's domain * Move frog's domain locations to regions for combat logic * Add new frog's domain regions for combat logic * Update region name for frog's domain * Fix typo * Add more regions for lower zig * Move around lower zig regions for combat logic * Lower Zig combat logic * Upper zig combat logic * Fix typo * Fix typos * Fix missing world. * Update combat logic description * Add todo * Add todo * Don't make zig skip if er or fixed shop is off * Make it so zig skip is only made with fewer shops and er * Temporarily default combat logic on * Update test to explicitly disable combat logic * Update test_access.py * Slight wording changes * Fix bugs, refactor quarry regions so you can access chests in lower quarry with ice grapples * Run through checks you can do with magic dagger * Run through checks you can do with magic dagger * Add rule for entering town portal of having equipment to deal with enemies * Add rule for atoll near the 6 crabs surrounding a poor defenseless baby slorm * Update the rule for the chest near the 6 crabs surrounding a slorm to also possibly require laurels * Revamp combat logic function to work properly without melee * Add laurels rules to combat logic chests * Modify beneath the vault bridge rule to need a lantern if combat logic is on * Put in money logic * Dagger or combat for swamp big skeleton chest * Remove the 100 moneys from logic * Modify lower zig ls drop region destinations * Remove completed todo * Reword combat logic option description, remove test option * Add combat logic to slot data * Merge Silent's missing slot data bugfix PR #3628 * Remove test combat option * Update combat logic description * Fix secret gathering place issue * Fix secret gathering place issue * Fix lower zig ls rule * Fix accidentally removed librarian rule * Remove redundant rule * Update gauntlet rule to hard-require a sword * Add test for a problematic connection * Adjust combat logic to deal with weird edge cases so it doesn't take stuff out of logic that was previously in logic * Fix create_item classification * Update some comments * Update per exempt's suggestion * Add combat logic to the well boss fight, reorder the combat logic stuff a little to better section them off * Add EntranceLayout option * Add back LogicRules as an invisible option, to not break old yamls * Fix a bug with seed group, continue changing fixed shop to entrance layout * Fix missed fixed shop -> entrance layout spot * Fix bug in seed groups with fixed shop on and off * Add entrance layout to the UT regen stuff * Put direction. in, will add them later * Remove unused elevation from portal class * Got like half of them in * Finish adding all of the directions * Add combat rule for zig front to back * Update per Medic's suggestion * Update ladder storage without items option description * Mess with state with collect and remove to save like 2 seconds (never again) * Save even more time, still never going to do this again on anything else * Add option check for collect and remove * Add directions to shop portals * Update direction in Portal with default * Move Direction above Portal * Add decoupled option, mess with plando connection stuff * Merge, implement verify plando directions * Condense the stuff in change and remove to less lines (thanks medic) * Remove unused thing * Swap to using logicmixin instead of prog_items (thanks Vi) * Fix consistency in stat counters * Add back something that was needed * Fix mistake when adding back * Making the fix better (thanks medic) * Make it actually return false if it gets to the backup lists and fails them * Fix stuff after merge * Add outlet regions, create new regions as needed for them * Put together part of decoupled and direction pairs * make direction pairs work * Make decoupled work * Make fixed shop work again * Fix a few minor bugs * Fix a few minor bugs * Fix plando * god i love programming * Reorder portal list * Update portal sorter for variable shops * Add missing parameter * Some cleanup of prints and functions * Fix typo * it's aliiiiiive * Make seed groups not sync decoupled * Add test with full-shop plando * Fix bug with vanilla portals * Handle plando connections and direction pair errors * Update plando checking for decoupled * Fix typo * Fix exception text to be shorter * Add some more comments * Add todo note * Remove unused safety thing * Remove extra plando connections definition in options * Make seed groups in decoupled with overlapping but not fully overlapped plando connections interact nicely without messing with what the entrances look like in the spoiler log * Fix weird edge case that is technically user error * Add note to fixed shop * Fix parsing shop names in UT * Remove debug print * Actually make UT work * multiworld. to world. * Fix typo from merge * Make it so the shops show up in the entrance hints * Fix bug in ladder storage rules * Remove blank line * # Conflicts: # worlds/tunic/__init__.py # worlds/tunic/er_data.py # worlds/tunic/er_rules.py # worlds/tunic/er_scripts.py # worlds/tunic/rules.py # worlds/tunic/test/test_access.py * Fix issues after merge * Update plando connections stuff in docs * Fix library mistake * has_stick -> has_melee * has_stick -> has_melee * Add a failsafe for direction pairing * Fix playthrough crash bug * Remove init from logicmixin * Updates per code review (thanks hesto) * has_stick to has_melee in newer update * has_stick to has_melee in newer update * # Conflicts: # worlds/tunic/__init__.py # worlds/tunic/combat_logic.py # worlds/tunic/er_data.py # worlds/tunic/er_rules.py # worlds/tunic/er_scripts.py * Cleanup more stuff after merge * Revert "Cleanup more stuff after merge" This reverts commit a6ee9a93da8f2fcc4413de6df6927b246017889d. * Revert "# Conflicts:" This reverts commit c74ccd74a45b6ad6b9abe6e339d115a0c98baf30. * Cleanup more stuff after merge * Swap to .get for decoupled so it works with older games probably maybe * Fix after merge * Fix typo * Fix UT support with fixed shop option * Backport plando connections fix * Fix issue with fixed shop + decoupled * Make the error not duplicate the while loop condition * Fix rule for quarry back to monastery * Fix more stuff after merge * Make it not output anything if you set plando connections but not ER * Add obvious note to plando connections description * Fix after merge * add comment to commented out connection --------- Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-05-06 12:33:21 -04:00
# The upgrade page is right by the Well entrance. Upper Overworld by the chest in the top right might need something
"Overworld": AreaStats(1, 1, 1, 1, 1, 1, 0, ["Stick"]),
"East Forest": AreaStats(1, 1, 1, 1, 1, 1, 0, ["Sword"]),
"Before Well": AreaStats(1, 1, 1, 1, 1, 1, 3, ["Sword", "Shield"]),
# learn how to upgrade
"Beneath the Well": AreaStats(2, 1, 3, 3, 1, 1, 3, ["Sword", "Shield"]),
"Dark Tomb": AreaStats(2, 2, 3, 3, 1, 1, 3, ["Sword", "Shield"]),
"West Garden": AreaStats(2, 3, 3, 3, 1, 1, 4, ["Sword", "Shield"]),
"Garden Knight": AreaStats(3, 3, 3, 3, 2, 1, 4, ["Sword", "Shield"], is_boss=True),
# get the wand here
"Beneath the Vault": AreaStats(3, 3, 3, 3, 2, 1, 4, ["Sword", "Shield", "Magic"]),
"Eastern Vault Fortress": AreaStats(3, 3, 3, 4, 3, 2, 4, ["Sword", "Shield", "Magic"]),
"Siege Engine": AreaStats(3, 3, 3, 4, 3, 2, 4, ["Sword", "Shield", "Magic"], is_boss=True),
"Frog's Domain": AreaStats(3, 4, 3, 5, 3, 3, 4, ["Sword", "Shield", "Magic"]),
# the second half of Atoll is the part you need the stats for, so putting it after frogs
"Ruined Atoll": AreaStats(4, 4, 3, 5, 3, 3, 5, ["Sword", "Shield", "Magic"]),
"The Librarian": AreaStats(4, 4, 3, 5, 3, 3, 5, ["Sword", "Shield", "Magic"], is_boss=True),
"Quarry": AreaStats(5, 4, 3, 5, 3, 3, 5, ["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),
"Swamp": 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),
}
# 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"]
# 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):
unchecked = 0
failed = 1
succeeded = 2
def has_combat_reqs(area_name: str, state: CollectionState, player: int) -> bool:
# we're caching whether you've met the combat reqs before if the state didn't change first
# if the combat state is stale, mark each area's combat state as stale
if state.tunic_need_to_reset_combat_from_collect[player]:
state.tunic_need_to_reset_combat_from_collect[player] = False
for name in area_data.keys():
if state.tunic_area_combat_state[player][name] == CombatState.failed:
state.tunic_area_combat_state[player][name] = CombatState.unchecked
if state.tunic_need_to_reset_combat_from_remove[player]:
state.tunic_need_to_reset_combat_from_remove[player] = False
for name in area_data.keys():
if state.tunic_area_combat_state[player][name] == CombatState.succeeded:
state.tunic_area_combat_state[player][name] = CombatState.unchecked
if state.tunic_area_combat_state[player][area_name] > CombatState.unchecked:
return state.tunic_area_combat_state[player][area_name] == CombatState.succeeded
met_combat_reqs = check_combat_reqs(area_name, state, player)
# we want to skip the "none area" since we don't record its results
if area_name not in area_data.keys():
return met_combat_reqs
# loop through the lists and set the easier/harder area states accordingly
if area_name in boss_areas:
area_list = boss_areas
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:
# set the state as true for each area until you get to the area we're looking at
for name in area_list:
state.tunic_area_combat_state[player][name] = CombatState.succeeded
if name == area_name:
break
else:
# set the state as false for the area we're looking at and each area after that
reached_name = False
for name in area_list:
if name == area_name:
reached_name = True
if reached_name:
state.tunic_area_combat_state[player][name] = CombatState.failed
return met_combat_reqs
def check_combat_reqs(area_name: str, state: CollectionState, player: int, alt_data: Optional[AreaStats] = None) -> bool:
data = alt_data or area_data[area_name]
extra_att_needed = 0
extra_def_needed = 0
extra_mp_needed = 0
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 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 -= 32
else:
return False
elif item == "Sword":
if not sword_bool:
# need sword for bosses
if data.is_boss:
return False
if stick_bool:
equipment.remove("Sword")
equipment.append("Stick")
# may revise this later based on feedback
extra_att_needed += 3
extra_def_needed += 2
# this is for when it changes over to the magic-only state if it needs to later
extra_mp_needed += 4
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
elif item == "Laurels":
if not state.has("Hero's Laurels", player):
# 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 -= 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,
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
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
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 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 + 2,
modified_stats.potion_count, equip_list, data.is_boss)
if check_combat_reqs("none", state, player, more_modified_stats):
return True
else:
return False
return False
# check if you have the required stats, and the money to afford them
# it may be innaccurate due to poor spending, and it may even require you to "spend poorly"
# 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
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 and "Magic" in data.equipment:
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:
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)
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
if free_sp + free_def >= req_stats:
# you don't need to buy upgrades
pass
else:
# 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)
player_hp, hp_offerings = get_hp_level(state, player)
player_potion_count = get_potion_count(state, player)
player_effective_hp = calc_effective_hp(player_hp, player_potion, player_potion_count)
if player_effective_hp < req_effective_hp:
return False
else:
# need a way to determine which of potion offerings or hp offerings you can reduce
free_potion = player_potion - potion_offerings
free_hp = player_hp - hp_offerings
if calc_effective_hp(free_hp, free_potion, player_potion_count) >= req_effective_hp:
# you don't need to buy upgrades
pass
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()
# 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
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
# returns a tuple of your max attack level, the number of attack offerings
def get_att_level(state: CollectionState, player: int) -> Tuple[int, int]:
att_offerings = state.count("ATT Offering", player)
att_upgrades = state.count("Hero Relic - ATT", player)
sword_level = state.count("Sword Upgrade", player)
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)
+ (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
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))
+ (2 if state.has("Shield", player) else 0)
+ (2 if state.has("Hero's Laurels", player) else 0),
def_offerings)
# returns a tuple of your max potion level, the number of potion offerings
def get_potion_level(state: CollectionState, player: int) -> Tuple[int, int]:
potion_offerings = min(2, state.count("Potion Offering", player))
# your third potion upgrade (from offerings) costs 1,000 money, reasonable to assume you won't do that
return (1 + potion_offerings
+ state.count_from_list({"Hero Relic - POTION", "Just Some Pals", "Spring Falls", "Back To Work"}, player),
potion_offerings)
# returns a tuple of your max hp level, the number of hp offerings
def get_hp_level(state: CollectionState, player: int) -> Tuple[int, int]:
hp_offerings = state.count("HP Offering", player)
return 1 + hp_offerings + state.count("Hero Relic - HP", player), hp_offerings
# returns a tuple of your max sp level, the number of sp offerings
def get_sp_level(state: CollectionState, player: int) -> Tuple[int, int]:
sp_offerings = state.count("SP Offering", player)
return (1 + sp_offerings
+ state.count_from_list({"Hero Relic - SP", "Mr Mayor", "Power Up",
"Regal Weasel", "Forever Friend"}, player),
sp_offerings)
def get_mp_level(state: CollectionState, player: int) -> Tuple[int, int]:
mp_offerings = state.count("MP Offering", player)
return (1 + mp_offerings
+ state.count_from_list({"Hero Relic - MP", "Sacred Geometry", "Vintage", "Dusty"}, player),
mp_offerings)
def get_potion_count(state: CollectionState, player: int) -> int:
return state.count("Potion Flask", player) + state.count("Flask Shard", player) // 3
def calc_effective_hp(hp_level: int, potion_level: int, potion_count: int) -> int:
player_hp = 60 + hp_level * 20
# since you don't tend to use potions efficiently all the time, scale healing by .75
total_healing = int(.75 * potion_count * min(player_hp, 20 + 10 * potion_level))
return player_hp + total_healing
# returns the total amount of progression money the player has
def get_money_count(state: CollectionState, player: int) -> int:
money: int = 0
# this could be done with something to parse the money count at the end of the string, but I don't wanna
money += state.count("Money x255", player) * 255 # 1 in pool
money += state.count("Money x200", player) * 200 # 1 in pool
money += state.count("Money x128", player) * 128 # 3 in pool
# total from regular money: 839
# first effigy is 8, doubles until it reaches 512 at number 7, after effigy 28 they stop dropping money
# with the vanilla count of 12, you get 3,576 money from effigies
effigy_count = min(28, state.count("Effigy", player)) # 12 in pool
money_per_break = 8
for _ in range(effigy_count):
money += money_per_break
money_per_break = min(512, money_per_break * 2)
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]
tunic_area_combat_state: Dict[int, Dict[str, int]]
def init_mixin(self, _):
# the per-player need to reset the combat state when collecting a combat item
self.tunic_need_to_reset_combat_from_collect = defaultdict(lambda: False)
# the per-player need to reset the combat state when removing a combat item
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