# Automatic Sebastian game player
# B551 Spring 2021
# PUT YOUR NAME AND USER ID HERE!: cdeshpa-ajpawale-svogiral-a2
#
# Based on skeleton code by D. Crandall
#
#
# This is the file you should modify to create your new smart player.
# The main program calls this program three times for each turn. 
#   1. First it calls first_roll, passing in a Dice object which records the
#      result of the first roll (state of 5 dice) and current Scorecard.
#      You should implement this method so that it returns a (0-based) list 
#      of dice indices that should be re-rolled.
#   
#   2. It then re-rolls the specified dice, and calls second_roll, with
#      the new state of the dice and scorecard. This method should also return
#      a list of dice indices that should be re-rolled.
#
#   3. Finally it calls third_roll, with the final state of the dice.
#      This function should return the name of a scorecard category that 
#      this roll should be recorded under. The names of the scorecard entries
#      are given in Scorecard.Categories.
#

from SebastianState import Dice
from SebastianState import Scorecard
import random

class SebastianAutoPlayer:

      def __init__(self):
            pass

      """
      The total_score() function evauates the total score for each category that is generated by the particular set of dice, and these values are stored in the dictionary- Categories. 
      Initially we convert the set of dice to a list of dice result. When we receive a set of dice or re-rolled set of dice, this function calculates the cost of the set of dice for 
      each category. The dictionary dice_dict maitains the count of dice result for that particular set of dice. eg. for [2,3,4,2,2], the dice_dict is : {1:0,2:3,3:1,4:1,5:0,6:0}. 
      Based on these values, we evaluate the score for "primis", "secundus", "tertium", "quartus", "quintus", "sextus", "triplex", "quadrupla", "squadron" and "quintuplicatam".  
      For "company" and "prattle", we simply sort the dice list and do a string comparison and send its score. For "pandemonium", we simply return the sum of all dice element, 
      which is stored in dice_sum. If the condition is satisfied, we assign quadrupula, triplex with the dice_sum value as well.

      We return the maximum value's key from the dictionary. The max_category stores the category of the maximum value. We also return the entire Categories dictionary, 
      incase the max_category has already been utilized. In such a case, we will be using the next maximum value's category there. We also return the maximum value as well, which will be 
      used to evaluate the expectation value.

      """

      def total_score(self,dice):

            dice=list(dice)
            Categories = {"primis":0, "secundus":0, "tertium":0, "quartus":0, "quintus":0, "sextus":0, "company":0, "prattle":0, "squadron":0, "triplex":0, "quadrupla":0, "quintuplicatam":0, "pandemonium":0}
            dice_dict={1:0,2:0,3:0,4:0,5:0,6:0}
            for i in dice:
                  dice_dict[i]+=1

            dice_sum=0
            for i in dice:
                  dice_sum+=i

            Categories["primis"]=dice_dict[1]*1
            Categories["secundus"]=dice_dict[2]*2
            Categories["tertium"]=dice_dict[3]*3
            Categories["quartus"]=dice_dict[4]*4
            Categories["quintus"]=dice_dict[5]*5
            Categories["sextus"]=dice_dict[6]*6
            dice.sort()
            if dice in [[1,2,3,4,5],[2,3,4,5,6]]:
                  Categories["company"]=40
                  Categories["prattle"]=30

            if 1 in dice and 2 in dice and 3 in dice and 4 in dice:
                  Categories["prattle"]=30
            elif 2 in dice and 3 in dice and 4 in dice and 5 in dice:
                  Categories["prattle"]=30      
            elif 3 in dice and 4 in dice and 5 in dice and 6 in dice:
                  Categories["prattle"]=30      

            max_key=max(dice_dict, key=dice_dict.get)

            if dice_dict[max_key]==3:
                  Categories["triplex"]=dice_sum

            if dice_dict[max_key]==4:
                  Categories["quadrupla"]=dice_sum
                  Categories["triplex"]=dice_sum

            if dice_dict[max_key]==5:
                  Categories["quintuplicatam"]=50
                  Categories["quadrupla"]=dice_sum
                  Categories["triplex"]=dice_sum
                  Categories["squadron"]=25


            if (dice_dict[max_key]==3) and (dice_dict[1]==2 or dice_dict[2]==2 or dice_dict[3]==2 or dice_dict[4]==2 or dice_dict[5]==2 or dice_dict[6]==2):
                  Categories["squadron"]=25

            Categories["pandemonium"]=dice_sum
                  
            max_category=max(Categories, key=Categories.get)

            return Categories[max_category],max_category,Categories
      

      """
      The expectation() function is the start of the expectimax evaluation. The expectation() function will help us to evaluate the expectation values of every category of reroll.
      For a set of 5 dice, We have 2**5=32 different possibilities of either roll or not roll. Based on these values, we generate the outcome_count, which we be using to evaluate 
      the expectation value. The exp value will store the result maximum score returned from total_score() function. We calculate the sum of the exp values to generate the total 
      expectation value of that particular level from the expectimax tree. The expection value is returned when we multiply the exp value by 1.0 and divide it by the outcome_count.

      """
      def expectation(self,roll,reroll):
            exp=0
            outcome_count=0
            for outcome_a in ((roll[0],) if not reroll[0] else range(1,7)):
                  for outcome_b in ((roll[1],) if not reroll[1] else range(1,7)):
                        for outcome_c in ((roll[2],) if not reroll[2] else range(1,7)):
                              for outcome_d in ((roll[3],) if not reroll[3] else range(1,7)):
                                    for outcome_e in ((roll[4],) if not reroll[4] else range(1,7)):
                                          exp+=self.total_score((outcome_a,outcome_b,outcome_c,outcome_d,outcome_e))[0]
                                          outcome_count+=1
                          
            return exp*1.0/outcome_count

      """
      Within the max_layer() function, we take all the possible values of whether we can reroll a dice or a subset of dice from the current categories or not. So we iterate over 
      5 times for each roll of the dice by selecting either True(reroll it) or False(Don't reroll it). We have 2**5==32 possibilities here if we can choose to reroll a dice or not.
      For each particular case, it calls the expectation function, and for each layer and same subtree in the expectimax tree, it returns the total expectation value. 
      Thus at every layer we can return the maximum expectation value(using max_so_far variable). Based on this max_so_far value, we generate the reroll list and use it as a decision to
      reroll.

      """
      def max_layer(self,roll):
            exp_score=0
            max_so_far=(0,0)
            for roll_a in (True,False):
                  for roll_b in (True,False):
                        for roll_c in (True,False):
                              for roll_d in (True,False):
                                    for roll_e in (True,False):
                                          exp_score=self.expectation(roll,(roll_a,roll_b,roll_c,roll_d,roll_e))
                                          if exp_score>max_so_far[1]:
                                                max_so_far=((roll_a,roll_b,roll_c,roll_d,roll_e),exp_score)
            reroll_list=[]

            for i in range(len(max_so_far[0])):
                  if max_so_far[0][i]==True:
                        reroll_list.append(i)      


            return reroll_list

      """
      The calculate_remaining() function returns the indices of those dice numbers which need to be rerolled whenever this is called. eg. for [1,2,2,5,2], we know that max_key is 2,
      so we return the indices of 1 and 5(not in max_key). The returned list in this case is [0,3].
      """

      def calculate_remaining(self,dice,max_key):
            reroll_list=[]
            
            for i in range(len(dice)):
                  if dice[i]!=max_key:
                        reroll_list.append(i)

            return reroll_list

      """
      The convert_dice_to_list() function will convert the string formatted dice to a list dice. eg. 2 3 4 5 6 will be converted to [2,3,4,5,6].

      """
      def convert_dice_to_list(self,dice):
            dice_string=(str(dice))
            dice_ele=dice_string.split()
            dice=[]
            for i in dice_ele:
                  i=int(i)
                  dice.append(i)
            return dice

      """      
      The first_roll() function receives the inputs as dice and the current scorecard. We initially convert the string formatted dice into list of dice results using 
      convert_dice_to_list() function. We then check the count of dice values of the current dice configuration like we check for calculate_remaining() function. eg. for [1,2,3,1,2], we have 
      dice_dict as {1:2,2:2,3:1,4:0,5:0,6:0}. Then we find out the max_key over here, which contains the maximum value of the dice_dict.

      As there is no fixed method to get a high score, we try to use a greedy approach. If the constraint doesn't allow us with the greedy approach, we use the expectimax algorithm.

      1. If we get the maximum value in dice_dict as 3 or greater than 3, we could try to achieve the first 6 categories, squadron, triplex, quadrupla or quintuplicatam. To achieve this, 
      we could use the calculate_remaining() function and only reroll the dice elements apart from the max_key.

      2. We also need to try to achieve the bonus of 35 points. So to achieve that, we try and check which value is the max_value and if its particular value isn't in
      scorecard.scorecard.keys(). eg. if max_key is 6, we need to check if "sextus" is not present in scorecard.scorecard.keys(). If not, then we will reroll those dice where 6 is not
      present. In the worst case, a "triplex" is guaranteed (assuming that triplex hasn't been used, otherwise, we could yield "sextus"). However, we have ignored the "primus" category,
      as it doesn't make much difference in the total score(max achievable is 5).

      3. Now the max achievable value from "sextus" is 6*5=30 and "quintus" is 5*5=25, whereas for squadron the value is 25. So we keep this above "primis", "secundus", "tertium" and "quartus".
      For dice_dict[max_key]==3, we will reroll the remaining dice where key is not equal to max_key, hoping to get "squadron".

      4. If none of the categories from 2 to 5 or "squadron" is available in scorecard.scorecard(), i.e. if len of reroll_list is 0, we try to achieve "quintuplicatam", "quadrupla", "triplex"
      by rerolling the remaining dice. If still we don't fulfill any criteria, we call the max_layer() function.

      5. We could also check in the dice_dict[key]==2. If such is the case, we try to check for the "secundus" to "quintus" categories, as it might help us to give the bonus.

      6. Apart from these we could also check for "company" and "prattle", by doing individual checks of each element of dice set in a similar manner. If that category is possible, we return
      an empty list to reroll, of course only if it has not been used in a previous scorecard.
   
      7. If none of the above categories are possible, we call the max_layer() function to evaluate based on the expectation values. 

      """

      def first_roll(self, dice, scorecard):
            
            dice=self.convert_dice_to_list(dice)

            dice_dict={1:0,2:0,3:0,4:0,5:0,6:0}

            for i in dice:
                  dice_dict[i]+=1
            reroll_list=[]
            max_key=max(dice_dict, key=dice_dict.get)

            if dice_dict[max_key]>=3:

                  
                  if dice_dict[6]>=3 and "sextus" not in scorecard.scorecard.keys():
                        reroll_list=self.calculate_remaining(dice,6)
                        
                  elif dice_dict[5]>=3 and "quintus" not in scorecard.scorecard.keys():
                        reroll_list=self.calculate_remaining(dice,5)
                        
                  elif dice_dict[max_key]==3 and "squadron" not in scorecard.scorecard.keys():
                        reroll_list=self.calculate_remaining(dice,max_key)

                  elif dice_dict[4]>=3 and "quartus" not in scorecard.scorecard.keys():
                        reroll_list=self.calculate_remaining(dice,4)
                        
                  elif dice_dict[3]>=3 and "tertium" not in scorecard.scorecard.keys():
                        reroll_list=self.calculate_remaining(dice,3)
                    
                  elif dice_dict[2]>=3 and "secundus" not in scorecard.scorecard.keys():
                        reroll_list=self.calculate_remaining(dice,2)

                  if len(reroll_list)==0:
                        if "quintuplicatam" not in scorecard.scorecard.keys():
                              reroll_list=self.calculate_remaining(dice,max_key)

                        elif "quadrupla" not in scorecard.scorecard.keys():
                              reroll_list=self.calculate_remaining(dice,max_key)

                        elif "triplex" not in scorecard.scorecard.keys():
                              reroll_list=self.calculate_remaining(dice,max_key)

                        else:
                              reroll_list=self.max_layer(dice)      
                  
                  return reroll_list     
                        

            elif dice_dict[6]==2 and "sextus" not in scorecard.scorecard.keys():
                  reroll_list=self.calculate_remaining(dice,6)
                  return reroll_list
            elif dice_dict[5]==2 and "quintus" not in scorecard.scorecard.keys():
                  reroll_list=self.calculate_remaining(dice,5)
                  return reroll_list
            elif dice_dict[4]==2 and "quartus" not in scorecard.scorecard.keys():
                  reroll_list=self.calculate_remaining(dice,4)
                  return reroll_list
            elif dice_dict[3]==2 and "tertium" not in scorecard.scorecard.keys():
                  reroll_list=self.calculate_remaining(dice,3)
                  return reroll_list
            elif dice_dict[2]==2 and "secundus" not in scorecard.scorecard.keys():
                  reroll_list=self.calculate_remaining(dice,2)
                  return reroll_list

            elif 2 in dice and 3 in dice and 4 in dice and 5 in dice and ((1 in dice) or (6 in dice)) and "company" not in scorecard.scorecard.keys():
                  reroll_list=[]
                  return reroll_list

            elif (1 in dice and 2 in dice and 3 in dice and 4 in dice) or (2 in dice and 3 in dice and 4 in dice and 5 in dice) or (3 in dice and 4 in dice and 5 in dice and 6 in dice) and "prattle" not in scorecard.scorecard.keys():
                  reroll_list=[]
                  return reroll_list

            else:
                  reroll_list=self.max_layer(dice)
            
            return reroll_list
      """
      The second_roll() function does the same tasks which first_roll() function, but only on a new set of dice outcomes. Thus we call the first_roll function again, and return a new
      reroll_list for the 3rd round.

      """
      def second_roll(self, dice, scorecard):

            reroll_list=self.first_roll(dice,scorecard)

            return reroll_list
      """      
      The third_roll() function will eventually calculate the maximum score which can be generated from the categories after the last reroll. The curr_categories will show the scores
      of all categories for the current dice configuration after we call the total_score() function. The only problem with everytime using the maximum score is that we may end up utilizing 
      a category which has already been used in the game. To solve this problem, we create a duplicate of scorecard.scorecard, named temp. Within temp, we delete all the categories 
      which have already been utilized, and we delete those categories from curr_categories. 

      Another problem that we face is especially towards the end, when the score of the remaining categories might be of value 0, so instead of not picking up any category,
      it randomly chooses a category with value 0, which we won't be able to utilize for a further round. The solution to that is to delete all categories with 
      scorecard.scorecard equal to 0 before we process any further by using a list named delete_empty. In such a case, we will never select any 0 valued entry and 
      skip to the next round.

      """
      def third_roll(self, dice, scorecard):
            dice=self.convert_dice_to_list(dice)
            max_score,max_category,curr_categories=self.total_score(dice)
            
            temp=scorecard.scorecard
            
            delete_empty=[]
            for key,value in temp.items():
                  if temp[key]==0:
                        delete_empty.append(key)

            for i in delete_empty:
                  del temp[i]

            for key in temp.keys():
                  del curr_categories[key]
            
            max_category=max(curr_categories, key=curr_categories.get)
            return max_category
            

            
            

