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
from enum import Enum
from typing import List, Any, Optional, Tuple
from typing import List, Any, Optional, Tuple, Set
from dataclasses import dataclass
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
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
BottomTopDeck = 20
PaceAfterSqueeze = 11 # pace goes down to 0 after cards have been forcibly lost due to hand size that *might* have helped prevent this situation.
BottomTopDeck = 20 # Card distribution in a single suit in starting hands + near end of deck is impossible to win. value represents suit Index
# 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
# Default reason when we have nothing else
SAT = 50
Manual = 60
class InfeasibilityReason:
@ -41,9 +37,7 @@ class InfeasibilityReason:
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)
@ -51,6 +45,14 @@ class InfeasibilityReason:
return "Out of hand size after drawing card {}".format(self.value)
case InfeasibilityType.CritAtBottom:
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]]):
@ -62,7 +64,9 @@ def generate_all_choices(l: List[List[Any]]):
for back in generate_all_choices(tail):
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)]
# 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:
positions_by_rank[rank].append(player)
# clean up where we have free choice anyway
for rank, positions in enumerate(positions_by_rank):
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:
positions.append(None)
# Now, iterate through all choices in starting hands (None stands for free choice of a card) and check them
assignment_found = False
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 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
return False
return None
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
"""
def analyze_2p_bottom_loss(instance: hanab_game.HanabiInstance) -> List[InfeasibilityReason]:
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
# 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 keep all non-trash cards in our hands
# - 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
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])
@ -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:
# 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])
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:
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:
# card is playable
stacks[card.suitIndex] += 1
@ -238,68 +209,68 @@ 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:
if card in stored_cards or card.rank == 5 or card in artificial_crits:
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:
if hand_size_left_for_crits == 0 and do_squeeze:
# 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
reasons.append(InfeasibilityReason(InfeasibilityType.HandSize, card_index))
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
max_remaining_plays = (instance.deck_size - card_index - 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 cur_pace < 0 and not pace_found:
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:
reasons.append(InfeasibilityReason(
InfeasibilityType.MultiSuitBdr,
i
))
reasons.append(InfeasibilityReason(InfeasibilityType.Pace, card_index))
pace_found = True
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):
database.cur.execute(
"SELECT seed, num_players, deck FROM seeds WHERE variant_id = (%s) ORDER BY (num_players, seed)",