240 lines
10 KiB
Python
240 lines
10 KiB
Python
from enum import Enum
|
|
from typing import List
|
|
|
|
import alive_progress
|
|
|
|
from hanabi import database
|
|
from hanabi import logger
|
|
from hanabi import hanab_game
|
|
from hanabi.live import compress
|
|
|
|
|
|
class InfeasibilityType(Enum):
|
|
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, value=None):
|
|
self.type = infeasibility_type
|
|
self.value = value
|
|
|
|
|
|
def __repr__(self):
|
|
return "{} ({})".format(self.type, self.value)
|
|
match self.type:
|
|
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 "Critical non-5 at bottom"
|
|
|
|
|
|
|
|
def analyze(instance: hanab_game.HanabiInstance, only_find_first=False) -> List[InfeasibilityReason]:
|
|
"""
|
|
Checks instance for the following (easy) certificates for unfeasibility
|
|
- There is a critical non-5 at the bottom
|
|
- We necessarily run out of pace when playing this deck:
|
|
At some point, among all drawn cards, there are too few playable ones so that the next discard
|
|
reduces pace to a negative amount
|
|
- We run out of hand size when playing this deck:
|
|
At some point, there are too many critical cards (that also could not have been played) for the players
|
|
to hold collectively
|
|
:param instance: Instance to be analyzed
|
|
:param only_find_first: If true, we immediately return when finding the first infeasibility reason and don't
|
|
check for further ones. Might be slightly faster on some instances, especially dark ones.
|
|
:return: List with all reasons found. Empty if none is found.
|
|
In particular, if return value is not the empty list, the analyzed instance is unfeasible
|
|
"""
|
|
reasons = []
|
|
|
|
# check for critical non-fives at bottom of the deck
|
|
bottom_card = instance.deck[-1]
|
|
if bottom_card.rank != 5 and bottom_card.suitIndex in instance.dark_suits:
|
|
reasons.append(InfeasibilityReason(
|
|
InfeasibilityType.CritAtBottom,
|
|
instance.deck_size - 1
|
|
))
|
|
if only_find_first:
|
|
return reasons
|
|
|
|
# we will sweep through the deck and pretend that
|
|
# - we keep all non-trash cards in our hands
|
|
# - we instantly play all playable cards as soon as we have them
|
|
# - we recurse on this instant-play
|
|
#
|
|
# For example, we assume that once we draw r2, we check if we can play r2.
|
|
# 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 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
|
|
#
|
|
# A small refinement is to pretend that we only have infinite storage for non-crit cards,
|
|
# 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
|
|
|
|
stacks = [0] * instance.num_suits
|
|
|
|
# we will ensure that stored_crits is a subset of stored_cards
|
|
stored_cards = set()
|
|
stored_crits = set()
|
|
|
|
pace_found = False
|
|
hand_size_found = False
|
|
squeeze = False
|
|
considered_bdr = False
|
|
artificial_crits = set()
|
|
|
|
# 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:
|
|
# card is playable
|
|
stacks[card.suitIndex] += 1
|
|
# check for further playables that we stored
|
|
for check_rank in range(card.rank + 1, 6):
|
|
check_card = hanab_game.DeckCard(card.suitIndex, check_rank)
|
|
if check_card in stored_cards:
|
|
stacks[card.suitIndex] += 1
|
|
stored_cards.remove(check_card)
|
|
if check_card in stored_crits:
|
|
stored_crits.remove(check_card)
|
|
else:
|
|
break
|
|
elif card.rank <= stacks[card.suitIndex]:
|
|
pass # card is trash
|
|
elif card.rank > stacks[card.suitIndex] + 1:
|
|
# need to store card
|
|
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)
|
|
|
|
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 hand_size_left_for_crits == 0:
|
|
# Note the very important copy here (!)
|
|
stored_cards = stored_crits.copy()
|
|
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 < 0 and not pace_found and not hand_size_found:
|
|
reasons.append(InfeasibilityReason(
|
|
InfeasibilityType.Pace,
|
|
i
|
|
))
|
|
if only_find_first:
|
|
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
|
|
|
|
return reasons
|
|
|
|
|
|
def run_on_database(variant_id):
|
|
database.cur.execute(
|
|
"SELECT seed, num_players, deck FROM seeds WHERE variant_id = (%s) ORDER BY (num_players, seed)",
|
|
(variant_id,)
|
|
)
|
|
res = database.cur.fetchall()
|
|
logger.verbose("Checking {} seeds of variant {} for infeasibility".format(len(res), variant_id))
|
|
with alive_progress.alive_bar(total=len(res), title='Check for infeasibility reasons in var {}'.format(variant_id)) as bar:
|
|
for (seed, num_players, deck_str) in res:
|
|
deck = compress.decompress_deck(deck_str)
|
|
reasons = analyze(hanab_game.HanabiInstance(deck, num_players))
|
|
for reason in reasons:
|
|
database.cur.execute(
|
|
"INSERT INTO score_upper_bounds (seed, score_upper_bound, reason) "
|
|
"VALUES (%s,%s,%s) "
|
|
"ON CONFLICT (seed, reason) DO UPDATE "
|
|
"SET score_upper_bound = EXCLUDED.score_upper_bound",
|
|
(seed, reason.score_upper_bound, reason.type.value)
|
|
)
|
|
database.cur.execute(
|
|
"UPDATE seeds SET feasible = (%s) WHERE seed = (%s)",
|
|
(False, seed)
|
|
)
|
|
bar()
|
|
database.conn.commit()
|