rework deck analyzer: more thorough analysis

also more detailed loss reasons
This commit is contained in:
Maximilian Keßler 2024-10-11 17:33:43 +02:00
parent eae229408d
commit 4f1bedd836

View file

@ -10,25 +10,39 @@ from hanabi.live import compress
class InfeasibilityType(Enum): class InfeasibilityType(Enum):
OutOfPace = 0 # idx denotes index of last card drawn before being forced to reduce pace, value denotes how bad pace is Pace = 0 # idx denotes index of last card drawn before being forced to reduce pace, value denotes how bad pace is
OutOfHandSize = 1 # idx denotes index of last card drawn before being forced to discard a crit DoubleBottom2With5s = 1 # same, special case for 2p
CritAtBottom = 3 TripleBottom1With5s = 2 # same, special case for 2p
MultiSuitBdr = 3
PaceAfterSqueeze = 4
HandSize = 10 # idx denotes index of last card drawn before being forced to discard a crit
HandSizeDirect = 11
HandSizeWithSqueeze = 12
HandSizeWithBdr = 13
HandSizeWithBdrSqueeze = 14
# further reasons, currently not scanned for
BottomTopDeck = 20
DoubleBottomTopDeck = 30
CritAtBottom = 40
class InfeasibilityReason: class InfeasibilityReason:
def __init__(self, infeasibility_type: InfeasibilityType, score_upper_bound, value=None): def __init__(self, infeasibility_type: InfeasibilityType, value=None):
self.type = infeasibility_type self.type = infeasibility_type
self.score_upper_bound = score_upper_bound
self.value = value self.value = value
def __repr__(self): def __repr__(self):
return "{} ({})".format(self.type, self.value)
match self.type: match self.type:
case InfeasibilityType.OutOfPace: case InfeasibilityType.Pace:
return "Upper bound {}, since deck runs out of pace after drawing card {}".format(self.score_upper_bound, self.value) return "Out of Pace after drawing card {}".format(self.value)
case InfeasibilityType.OutOfHandSize: case InfeasibilityType.HandSize:
return "Upper bound {}, since deck runs out of hand size after drawing card {}".format(self.score_upper_bound, self.value) return "Out of hand size after drawing card {}".format(self.value)
case InfeasibilityType.CritAtBottom: case InfeasibilityType.CritAtBottom:
return "Upper bound {}, sicne deck has critical non-5 at bottom".format(self.score_upper_bound) return "Critical non-5 at bottom"
def analyze(instance: hanab_game.HanabiInstance, only_find_first=False) -> List[InfeasibilityReason]: def analyze(instance: hanab_game.HanabiInstance, only_find_first=False) -> List[InfeasibilityReason]:
@ -54,7 +68,6 @@ def analyze(instance: hanab_game.HanabiInstance, only_find_first=False) -> List[
if bottom_card.rank != 5 and bottom_card.suitIndex in instance.dark_suits: if bottom_card.rank != 5 and bottom_card.suitIndex in instance.dark_suits:
reasons.append(InfeasibilityReason( reasons.append(InfeasibilityReason(
InfeasibilityType.CritAtBottom, InfeasibilityType.CritAtBottom,
instance.max_score - 5 + bottom_card.rank,
instance.deck_size - 1 instance.deck_size - 1
)) ))
if only_find_first: if only_find_first:
@ -69,7 +82,7 @@ def analyze(instance: hanab_game.HanabiInstance, only_find_first=False) -> List[
# If yes, then we also check if we drew r3 earlier and so on. # If yes, then we also check if we drew r3 earlier and so on.
# If not, then we keep r2 in our hands # If not, then we keep r2 in our hands
# #
# In total, this is equivalent to assuming that we infinitely many clues # In total, this is equivalent to assuming that we have infinitely many clues
# and infinite storage space in our hands (which is of course not true), # and infinite storage space in our hands (which is of course not true),
# but even in this setting, some games are infeasible due to pace issues # but even in this setting, some games are infeasible due to pace issues
# that we can detect # that we can detect
@ -78,7 +91,6 @@ def analyze(instance: hanab_game.HanabiInstance, only_find_first=False) -> List[
# for crit-cards, the usual hand card limit applies. # for crit-cards, the usual hand card limit applies.
# This allows us to detect some seeds where there are simply too many unplayable cards to hold at some point # This allows us to detect some seeds where there are simply too many unplayable cards to hold at some point
# that also can't be discarded # that also can't be discarded
# this allows us to detect standard pace issue arguments
stacks = [0] * instance.num_suits stacks = [0] * instance.num_suits
@ -86,11 +98,40 @@ def analyze(instance: hanab_game.HanabiInstance, only_find_first=False) -> List[
stored_cards = set() stored_cards = set()
stored_crits = set() stored_crits = set()
min_forced_pace = instance.initial_pace pace_found = False
worst_pace_index = 0 hand_size_found = False
squeeze = False
considered_bdr = False
artificial_crits = set()
max_forced_crit_discard = 0 # Investigate BDRs. This catches special cases of Pace losses in 2p, as well as mark some cards critical because
worst_crit_index = 0 # their second copies cannot be used.
filtered_deck = [card for card in instance.deck if card.rank != 5]
if instance.num_players == 2:
if filtered_deck[-1] == filtered_deck[-2] and filtered_deck[-1].rank == 2:
reasons.append(InfeasibilityReason(InfeasibilityType.Pace, filtered_deck[-2].deck_index - 1))
if only_find_first:
return reasons
reasons.append(InfeasibilityReason(InfeasibilityType.DoubleBottom2With5s, filtered_deck[-2].deck_index - 1))
pace_found = True
if filtered_deck[-1] == filtered_deck[-2] and filtered_deck[-2] == filtered_deck[-3] and filtered_deck[-3].rank == 1:
reasons.append(InfeasibilityReason(InfeasibilityType.Pace, filtered_deck[-3].deck_index - 1))
if only_find_first:
return reasons
reasons.append(InfeasibilityReason(InfeasibilityType.DoubleBottom2With5s, filtered_deck[-2].deck_index - 1))
pace_found = True
# In 2-player, the second-last card cannot be played if it is a 2
if filtered_deck[-2].rank == 2:
artificial_crits.add(filtered_deck[-2])
# In 2-player, in case there is double bottom 3 of the same suit, the card immediately before cannot be played:
# After playing that one and drawing the first 3, exactly 3,4,5 of the bottom suit have to be played
if filtered_deck[-1] == filtered_deck[-2] and filtered_deck[-2].rank == 3:
artificial_crits.add(filtered_deck[-2])
# Last card in the deck can never be played
artificial_crits.add(filtered_deck[-1])
for (i, card) in enumerate(instance.deck): for (i, card) in enumerate(instance.deck):
if card.rank == stacks[card.suitIndex] + 1: if card.rank == stacks[card.suitIndex] + 1:
@ -110,66 +151,71 @@ def analyze(instance: hanab_game.HanabiInstance, only_find_first=False) -> List[
pass # card is trash pass # card is trash
elif card.rank > stacks[card.suitIndex] + 1: elif card.rank > stacks[card.suitIndex] + 1:
# need to store card # need to store card
if card in stored_cards or card.rank == 5 or card == instance.deck[-1]: if card in stored_cards or card.rank == 5:
stored_crits.add(card) stored_crits.add(card)
elif card in artificial_crits:
stored_crits.add(card)
considered_bdr = True
stored_cards.add(card) stored_cards.add(card)
# logger.verbose("draw pile: {}\nstacks: {}\nstored: {}\nstored crits: {}".format(instance.deck[i+1:], stacks, stored_cards, stored_crits))
# check for out of handsize (this number can be negative, in which case nothing applies) hand_size_left_for_crits = instance.num_players * instance.hand_size - len(stored_crits) - 1
# Note the +1 at the end, which is there because we have to discard next,
# so even if we currently have as many crits as we can hold, we have to discard one
num_forced_crit_discards = len(stored_crits) - instance.num_players * instance.hand_size + 1
if num_forced_crit_discards > max_forced_crit_discard:
worst_crit_index = i
max_forced_crit_discard = num_forced_crit_discards
if only_find_first:
reasons.append(InfeasibilityReason(
InfeasibilityType.OutOfPace,
instance.max_score + min_forced_pace,
worst_pace_index
))
return reasons
# In case we can only keep the critical cards exactly, get rid of all others # In case we can only keep the critical cards exactly, get rid of all others
if num_forced_crit_discards == 0: if hand_size_left_for_crits == 0:
stored_cards = stored_crits stored_cards = stored_crits
squeeze = True
# Use a bool flag to only mark this reason once
if hand_size_left_for_crits < 0 and not hand_size_found:
reasons.append(InfeasibilityReason(
InfeasibilityType.HandSize,
i
))
if only_find_first:
return reasons
hand_size_found = True
# More detailed analysis of loss, categorization only
if squeeze:
if considered_bdr:
reasons.append(InfeasibilityReason(InfeasibilityType.HandSizeWithBdrSqueeze, i))
else:
reasons.append(InfeasibilityReason(InfeasibilityType.HandSizeWithSqueeze, i))
else:
if considered_bdr:
reasons.append(InfeasibilityReason(InfeasibilityType.HandSizeWithBdr, i))
else:
reasons.append(InfeasibilityReason(InfeasibilityType.HandSizeDirect, i))
# the last - 1 is there because we have to discard 'next', causing a further draw # the last - 1 is there because we have to discard 'next', causing a further draw
max_remaining_plays = (instance.deck_size - i - 1) + instance.num_players - 1 max_remaining_plays = (instance.deck_size - i - 1) + instance.num_players - 1
needed_plays = instance.max_score - sum(stacks) needed_plays = instance.max_score - sum(stacks)
cur_pace = max_remaining_plays - needed_plays cur_pace = max_remaining_plays - needed_plays
if cur_pace < min(0, min_forced_pace): if cur_pace < 0 and not pace_found and not hand_size_found:
min_forced_pace = cur_pace
worst_pace_index = i
if only_find_first:
reasons.append(InfeasibilityReason( reasons.append(InfeasibilityReason(
InfeasibilityType.OutOfPace, InfeasibilityType.Pace,
instance.max_score + min_forced_pace, i
worst_pace_index
)) ))
if only_find_first:
return reasons return reasons
# We checked single-suit pace losses beforehand (which can only occur in 2p)
if squeeze:
reasons.append(InfeasibilityReason(InfeasibilityType.PaceAfterSqueeze, i))
else:
reasons.append(InfeasibilityReason(
InfeasibilityType.MultiSuitBdr,
i
))
pace_found = True
# check that we correctly walked through the deck # check that we correctly walked through the deck
assert (len(stored_cards) == 0) assert (len(stored_cards) == 0)
assert (len(stored_crits) == 0) assert (len(stored_crits) == 0)
assert (sum(stacks) == instance.max_score) assert (sum(stacks) == instance.max_score)
if max_forced_crit_discard > 0:
reasons.append(
InfeasibilityReason(
InfeasibilityType.OutOfHandSize,
instance.max_score - max_forced_crit_discard,
worst_crit_index
)
)
if min_forced_pace < 0:
reasons.append(InfeasibilityReason(
InfeasibilityType.OutOfPace,
instance.max_score + min_forced_pace,
worst_pace_index
))
return reasons return reasons