Rework analyzing seeds and outcome categories

This commit is contained in:
Maximilian Keßler 2024-11-23 15:33:02 +01:00
parent 1027184c59
commit a4b9112ce7

View file

@ -1,6 +1,6 @@
import collections import collections
from enum import Enum from enum import Enum
from typing import List, Any, Optional, Tuple from typing import List, Any, Optional, Tuple, Set
from dataclasses import dataclass from dataclasses import dataclass
import alive_progress import alive_progress
@ -19,21 +19,17 @@ class InfeasibilityType(Enum):
Pace = 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
DoubleBottom2With5s = 1 # same, special case for 2p DoubleBottom2With5s = 1 # same, special case for 2p
TripleBottom1With5s = 2 # 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 HandSize = 10 # idx denotes index of last card drawn before being forced to discard a crit
HandSizeDirect = 11 PaceAfterSqueeze = 11 # pace goes down to 0 after cards have been forcibly lost due to hand size that *might* have helped prevent this situation.
HandSizeWithSqueeze = 12 BottomTopDeck = 20 # Card distribution in a single suit in starting hands + near end of deck is impossible to win. value represents suit Index
HandSizeWithBdr = 13
HandSizeWithBdrSqueeze = 14
BottomTopDeck = 20
# further reasons, currently not scanned for # further reasons, currently not scanned for
DoubleBottomTopDeck = 30 DoubleBottomTopDeck = 30 # Card distribution in two suits in starting hands + near end of deck is impossible to win.
CritAtBottom = 40 CritAtBottom = 40
# Default reason when we have nothing else # Default reason when we have nothing else
SAT = 50 SAT = 50
Manual = 60
class InfeasibilityReason: class InfeasibilityReason:
@ -41,9 +37,7 @@ class InfeasibilityReason:
self.type = infeasibility_type self.type = infeasibility_type
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.Pace: case InfeasibilityType.Pace:
return "Out of Pace after drawing card {}".format(self.value) return "Out of Pace after drawing card {}".format(self.value)
@ -51,6 +45,14 @@ class InfeasibilityReason:
return "Out of hand size after drawing card {}".format(self.value) return "Out of hand size after drawing card {}".format(self.value)
case InfeasibilityType.CritAtBottom: case InfeasibilityType.CritAtBottom:
return "Critical non-5 at bottom" return "Critical non-5 at bottom"
case _:
return "{} ({})".format(self.type, self.value)
def __eq__(self, other):
return self.type == other.type and self.value == other.value
def __hash__(self):
return (self.type, self.value).__hash__()
def generate_all_choices(l: List[List[Any]]): def generate_all_choices(l: List[List[Any]]):
@ -62,7 +64,9 @@ def generate_all_choices(l: List[List[Any]]):
for back in generate_all_choices(tail): for back in generate_all_choices(tail):
yield [option] + back yield [option] + back
def check_for_top_bottom_deck_loss(instance: hanab_game.HanabiInstance) -> bool:
# Returns index of the suit that makes deck infeasible, or None if it does not exist
def check_for_top_bottom_deck_loss(instance: hanab_game.HanabiInstance) -> Optional[int]:
hands = [instance.deck[p * instance.hand_size : (p+1) * instance.hand_size] for p in range(instance.num_players)] hands = [instance.deck[p * instance.hand_size : (p+1) * instance.hand_size] for p in range(instance.num_players)]
# scan the deck in reverse order if any card is forced to be late # scan the deck in reverse order if any card is forced to be late
@ -86,7 +90,6 @@ def check_for_top_bottom_deck_loss(instance: hanab_game.HanabiInstance) -> bool:
if card_test == card_hand: if card_test == card_hand:
positions_by_rank[rank].append(player) positions_by_rank[rank].append(player)
# clean up where we have free choice anyway # clean up where we have free choice anyway
for rank, positions in enumerate(positions_by_rank): for rank, positions in enumerate(positions_by_rank):
if rank != 5 and len(positions) < 2: if rank != 5 and len(positions) < 2:
@ -94,8 +97,6 @@ def check_for_top_bottom_deck_loss(instance: hanab_game.HanabiInstance) -> bool:
if len(positions) == 0: if len(positions) == 0:
positions.append(None) positions.append(None)
# Now, iterate through all choices in starting hands (None stands for free choice of a card) and check them # Now, iterate through all choices in starting hands (None stands for free choice of a card) and check them
assignment_found = False assignment_found = False
for assignment in generate_all_choices(positions_by_rank): for assignment in generate_all_choices(positions_by_rank):
@ -118,47 +119,28 @@ def check_for_top_bottom_deck_loss(instance: hanab_game.HanabiInstance) -> bool:
# If no assignment worked out, the deck is infeasible because of this suit # If no assignment worked out, the deck is infeasible because of this suit
if not assignment_found: if not assignment_found:
return True return card.suitIndex
# If we reach this point, we checked for every card near the bottom of the deck and found a possible endgame each # If we reach this point, we checked for every card near the bottom of the deck and found a possible endgame each
return False return None
def analyze_2p_bottom_loss(instance: hanab_game.HanabiInstance) -> List[InfeasibilityReason]:
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 = [] reasons = []
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))
reasons.append(InfeasibilityReason(InfeasibilityType.DoubleBottom2With5s, filtered_deck[-2].deck_index - 1))
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))
reasons.append(InfeasibilityReason(InfeasibilityType.TripleBottom1With5s, filtered_deck[-2].deck_index - 1))
top_bottom_deck_loss = check_for_top_bottom_deck_loss(instance)
if top_bottom_deck_loss:
reasons.append(InfeasibilityReason(InfeasibilityType.BottomTopDeck))
if only_find_first:
return reasons return 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
def analyze_pace_and_hand_size(instance: hanab_game.HanabiInstance, do_squeeze: bool = True) -> List[InfeasibilityReason]:
reasons = []
# we will sweep through the deck and pretend that # we will sweep through the deck and pretend that
# - we keep all non-trash cards in our hands # - we keep all non-trash cards in our hands
# - we instantly play all playable cards as soon as we have them # - we instantly play all playable cards as soon as we have them
@ -187,26 +169,12 @@ def analyze(instance: hanab_game.HanabiInstance, only_find_first=False) -> List[
pace_found = False pace_found = False
hand_size_found = False hand_size_found = False
squeeze = False squeeze = False
considered_bdr = False
artificial_crits = set() artificial_crits = set()
# Investigate BDRs. This catches special cases of Pace losses in 2p, as well as mark some cards critical because # 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. # their second copies cannot be used.
filtered_deck = [card for card in instance.deck if card.rank != 5] filtered_deck = [card for card in instance.deck if card.rank != 5]
if instance.num_players == 2: 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 # In 2-player, the second-last card cannot be played if it is a 2
if filtered_deck[-2].rank == 2: if filtered_deck[-2].rank == 2:
artificial_crits.add(filtered_deck[-2]) artificial_crits.add(filtered_deck[-2])
@ -214,13 +182,16 @@ def analyze(instance: hanab_game.HanabiInstance, only_find_first=False) -> List[
# In 2-player, in case there is double bottom 3 of the same suit, the card immediately before cannot be played: # 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 # 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: if filtered_deck[-1] == filtered_deck[-2] and filtered_deck[-2].rank == 3:
artificial_crits.add(filtered_deck[-2]) artificial_crits.add(filtered_deck[-3])
elif instance.num_players == 3:
if filtered_deck[-1] == filtered_deck[-2] and filtered_deck[-2].rank == 2:
artificial_crits.add(filtered_deck[-3])
# Last card in the deck can never be played # Last card in the deck can never be played unless it is a five.
if instance.deck[-1].rank != 5: if instance.deck[-1].rank != 5:
artificial_crits.add(instance.deck[-1]) artificial_crits.add(instance.deck[-1])
for (i, card) in enumerate(instance.deck): for (card_index, card) in enumerate(instance.deck):
if card.rank == stacks[card.suitIndex] + 1: if card.rank == stacks[card.suitIndex] + 1:
# card is playable # card is playable
stacks[card.suitIndex] += 1 stacks[card.suitIndex] += 1
@ -238,68 +209,68 @@ 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: if card in stored_cards or card.rank == 5 or card in artificial_crits:
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)
hand_size_left_for_crits = instance.num_players * instance.hand_size - len(stored_crits) - 1 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 # In case we can only keep the critical cards exactly, get rid of all others
if hand_size_left_for_crits == 0: if hand_size_left_for_crits == 0 and do_squeeze:
# Note the very important copy here (!) # Note the very important copy here (!)
stored_cards = stored_crits.copy() stored_cards = stored_crits.copy()
squeeze = True squeeze = True
# Use a bool flag to only mark this reason once # Use a bool flag to only mark this reason once
if hand_size_left_for_crits < 0 and not hand_size_found: if hand_size_left_for_crits < 0 and not hand_size_found:
reasons.append(InfeasibilityReason( reasons.append(InfeasibilityReason(InfeasibilityType.HandSize, card_index))
InfeasibilityType.HandSize,
i
))
if only_find_first:
return reasons
hand_size_found = True 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 - card_index - 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 < 0 and not pace_found and not hand_size_found: if cur_pace < 0 and not pace_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: if squeeze:
reasons.append(InfeasibilityReason(InfeasibilityType.PaceAfterSqueeze, i)) # We checked single-suit pace losses beforehand (which can only occur in 2p)
reasons.append(InfeasibilityReason(InfeasibilityType.PaceAfterSqueeze, card_index))
else: else:
reasons.append(InfeasibilityReason( reasons.append(InfeasibilityReason(InfeasibilityType.Pace, card_index))
InfeasibilityType.MultiSuitBdr,
i
))
pace_found = True pace_found = True
return reasons return reasons
def analyze(instance: hanab_game.HanabiInstance) -> List[InfeasibilityReason]:
reasons: List[InfeasibilityReason] = []
# Top/bottom deck losses in a single suit.
top_bottom_deck_loss = check_for_top_bottom_deck_loss(instance)
if top_bottom_deck_loss is not None:
reasons.append(InfeasibilityReason(InfeasibilityType.BottomTopDeck, top_bottom_deck_loss))
# Special cases of pace loss, categorization for 2p only
reasons += analyze_2p_bottom_loss(instance)
# 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
))
# Check for pace and hand size problems:
reasons += analyze_pace_and_hand_size(instance)
# In case pace ran out after a squeeze from hand size, we want to run a clean pace analysis again
if any(map(lambda r: r.type == InfeasibilityType.PaceAfterSqueeze, reasons)):
reasons += analyze_pace_and_hand_size(instance, False)
# clean up reasons to unique
return list(set(reasons))
def run_on_database(variant_id): def run_on_database(variant_id):
database.cur.execute( database.cur.execute(
"SELECT seed, num_players, deck FROM seeds WHERE variant_id = (%s) ORDER BY (num_players, seed)", "SELECT seed, num_players, deck FROM seeds WHERE variant_id = (%s) ORDER BY (num_players, seed)",