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):
OutOfPace = 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
CritAtBottom = 3
Pace = 0 # idx denotes index of last card drawn before being forced to reduce pace, value denotes how bad pace is
DoubleBottom2With5s = 1 # same, special case for 2p
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:
def __init__(self, infeasibility_type: InfeasibilityType, score_upper_bound, value=None):
def __init__(self, infeasibility_type: InfeasibilityType, value=None):
self.type = infeasibility_type
self.score_upper_bound = score_upper_bound
self.value = value
def __repr__(self):
return "{} ({})".format(self.type, self.value)
match self.type:
case InfeasibilityType.OutOfPace:
return "Upper bound {}, since deck runs out of pace after drawing card {}".format(self.score_upper_bound, self.value)
case InfeasibilityType.OutOfHandSize:
return "Upper bound {}, since deck runs out of hand size after drawing card {}".format(self.score_upper_bound, self.value)
case InfeasibilityType.Pace:
return "Out of Pace after drawing card {}".format(self.value)
case InfeasibilityType.HandSize:
return "Out of hand size after drawing card {}".format(self.value)
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]:
@ -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:
reasons.append(InfeasibilityReason(
InfeasibilityType.CritAtBottom,
instance.max_score - 5 + bottom_card.rank,
instance.deck_size - 1
))
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 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),
# but even in this setting, some games are infeasible due to pace issues
# 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.
# 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
# this allows us to detect standard pace issue arguments
stacks = [0] * instance.num_suits
@ -86,11 +98,40 @@ def analyze(instance: hanab_game.HanabiInstance, only_find_first=False) -> List[
stored_cards = set()
stored_crits = set()
min_forced_pace = instance.initial_pace
worst_pace_index = 0
pace_found = False
hand_size_found = False
squeeze = False
considered_bdr = False
artificial_crits = set()
max_forced_crit_discard = 0
worst_crit_index = 0
# Investigate BDRs. This catches special cases of Pace losses in 2p, as well as mark some cards critical because
# 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):
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
elif card.rank > stacks[card.suitIndex] + 1:
# 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)
elif card in artificial_crits:
stored_crits.add(card)
considered_bdr = True
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)
# 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
hand_size_left_for_crits = instance.num_players * instance.hand_size - len(stored_crits) - 1
# 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
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
max_remaining_plays = (instance.deck_size - i - 1) + instance.num_players - 1
needed_plays = instance.max_score - sum(stacks)
cur_pace = max_remaining_plays - needed_plays
if cur_pace < min(0, min_forced_pace):
min_forced_pace = cur_pace
worst_pace_index = i
if cur_pace < 0 and not pace_found and not hand_size_found:
reasons.append(InfeasibilityReason(
InfeasibilityType.Pace,
i
))
if only_find_first:
reasons.append(InfeasibilityReason(
InfeasibilityType.OutOfPace,
instance.max_score + min_forced_pace,
worst_pace_index
))
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
assert (len(stored_cards) == 0)
assert (len(stored_crits) == 0)
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