Source code for core

"""The Nim Game core

This main class implements the init and maintenance of heaps, movements of
coins, records the statuses and calculates decisions.
"""

from __future__ import annotations
from dataclasses import dataclass
import itertools
import random; random.seed()
import valid8 as v8
from source.typedefs import Move, ErrorRate, ErrorRate_T, ErrorRateTypes, \
    MyTurn_T, HeapCoinRange, HeapCoinRange_T, HeapCoinRangeTypes
from source import calculations


[docs]@dataclass class Nim(calculations.Mixin): """The main class to implement the Nim game This class publishes the following methods. See also the calculation methods from the mixin in :mod:`calculations`. **Instance Methods:** :meth:`setup_heaps` This creates and initiates the :attr:`heaps` attribute. :meth:`set_start` It sets which party is to start the game. :meth:`do_move` It makes a move, and swaps the :attr:`activeplayer`. :meth:`get_heapstatus` You can get the :attr:`heaps` status (coins in heaps) in a string. :meth:`game_end` It tells you whether the game has ended. **Instance Attributes:** Attributes: heaps: This :obj:`list` contans the number of coins in the heaps. This is initiated in :meth:`setup_heaps` and the actual status can be retrieved with :meth:`get_heapstatus`. activeplayer: This :obj:`str` is either the "Computer" or "Player" during the game. Or "start" in the very beginning when starting player has not been selected yet. Args: error_rate: The rate percentage of mistakes the Computer is to make. By deafult it is 0%, i.e. the computer never makes mistakes, and the Player has no chance to win (unless the Player decides who to start and never makes mistakes). If it was set to the extreme 100%, the Computer would always intentionally make wrong moves, practically making all attempts to lose. NB, if the Player does the same, the Computer may still win. For testing, when moves of both parties are calculated by the program, the error rate of both parties can be set with the :obj:`typedefs.ErrorRate`. This can be used to create staistics of games played by differently skilled players. NB, still, the 1\ :sup:`st` party will be reported in the results as Computer, and the 2\ :sup:`nd` as Player, just to name them. heapcount_range: The min and max number of heaps to be set up. Less than 3 heaps makes little sense, more than 15 is a bit of an overkill. So, the default is (3-15), but it can be set otherwise. NB, since the heaps are displayed with letters (A-Z), the heap number is limited to 26. coincount_range: The min and max number of coins to be set up in a heap. Less than 1 coin makes no sense, more than 20 is a bit of an overkill. So, the default is (1-20). NB, since the heap contents are displayed in max 2 digits, the coin number is limited to 99. misere: Whether it is the "misère" version of the game. """ error_rate: ErrorRate_T=0 heapcount_range: HeapCoinRange_T=(3, 15) coincount_range: HeapCoinRange_T=(1, 20) misere: bool=True def __post_init__(self): """Validate all input parameters for the instantiation This is quite a paranoid overkill, but it was created as an excercise of playing with a valid8 package. If any validation fails, exception is raised with detailed messages. """ #validate the error rate self.set_error_rate(self.error_rate) #validate the heapcount_range type v8.validate( name='heapcount_range', value=self.heapcount_range, instance_of=HeapCoinRangeTypes, custom=v8.validation_lib.collections.has_length(2) ) #make sure the heap range is a namedtuple from here on self.heapcount_range = HeapCoinRange(*self.heapcount_range) #validate the heap range ends; less than 1 heap makes no any sense; 26 # heaps are from A to Z, no more to allow v8.validate( name='heapcount_range.min', value=self.heapcount_range.min, instance_of=int, min_value=1 ) v8.validate( name='heapcount_range.max', value=self.heapcount_range.max, instance_of=int, max_value=26 ) #validate the heap range ends, i.e. min <= max v8.validate( name='heapcount_range min<max', value=self.heapcount_range.max-self.heapcount_range.min, min_value=0, help_msg='Range max bigger than min' ) #validate the coincount_range type v8.validate( name='coincount_range', value=self.coincount_range, instance_of=HeapCoinRangeTypes ) #validate the coincount_range size v8.validate( name='coincount_range', value=self.coincount_range, custom=v8.validation_lib.collections.has_length(2) ) #make sure the coin range is a namedtuple from here on self.coincount_range = HeapCoinRange(*self.coincount_range) #validate the coin range ends v8.validate( name='coincount_range.min', value=self.coincount_range.min, instance_of=int ) v8.validate( name='coincount_range.max', value=self.coincount_range.max, instance_of=int ) #validate the coin range min; must be a natural number, including zero v8.validate( name='"coincount_range" min', value=self.coincount_range.min, min_value=0 ) #validate the coin range max; being displayed as 2 digit, its max is 99 v8.validate( name='"coincount_range" max', value=self.coincount_range.max, max_value=99 ) #validate the coin range ends, i.e. min <= max v8.validate( name='coincount_range min<max', value=self.coincount_range.max-self.coincount_range.min, min_value=0, help_msg='Range max bigger than min' ) #validate the misere flag v8.validate( name='misere', value=self.misere, instance_of=bool ) #reset active player self.activeplayer='start'
[docs] def setup_heaps(self, heapcounts: list=None ) -> None: """Initial setup of heaps of coins If `heapcounts` is provided the number of heaps and the number of coins in each heap is checked whether they fall into the set valid range. Exception is raised if not. Args: heapcounts: The list elements state the number of coins in each heap. If no list is provided, a random number of heaps are created with a random number of coins. """ #random heaps to be created? if not heapcounts: heapcounts = [] #create a random int for the number of heaps heapnumber = random.randint(*self.heapcount_range) for _ in range(heapnumber): #create a random int for the number of coins in the heap coincount = random.randint(*self.coincount_range) #create the heap with the number of coins heapcounts.append(coincount) else: #validate the number of heaps v8.validate( name='Heaps in "heapcounts"', value=len(heapcounts), custom=v8.validation_lib.comparables.between( *self.heapcount_range ) ) #loop on all heaps for idx, heap in enumerate(heapcounts): #validate the number of coins in the given heap v8.validate( name=f'Coins of heap#{idx} in "heapcounts"', value=heap, custom=v8.validation_lib.comparables.between( *self.coincount_range ) ) #create the heaps list self.heaps = heapcounts
[docs] def set_start(self, myturn: MyTurn_T='a' ) -> None: """Set the starting party User can explicitly set which party is to start. This makes sense if this decision is made based on the starting heap status. You can also ask for random selection. By default the computer makes the decision using 2 factors. First it figures out whether the heap status is beneficial for starting, i.e. it is a winning status. Then it checks the required error rate and it intentionally makes a wrong decision with higher likelihood if the error_rate is higher. The `myturn` argument is converted to a boolean, if it arrives as a :obj:`str`, see the parameter description below. No issues with "overwriting" it, because :obj:`str` is unmutable, i.e. the default remains the 'a' for subsequent calls and also the outer variable, where the argument comes from, remains unaffected. The (converted) `myturn` value is then used in a :obj:`bool` context. If it is of any other type, it is still interpreted as a bool. E.g. an empty list will act like a False, i.e. Computer will start. The attribute `nextturn` is a :meth:`__next__` of :obj:`itertools.cycle`. Calling "nextturn()" infinitely yields the swapped player names. This attribute is used in :meth:`do_move` to change the active player. Args: myturn: The logical value from the Player's point of view. - 'a' (by default): the Computer figures out which party is to start the game, based on the heap counts and the required error rate - truthy or 'p': Player - falsy or 'c': Computer - 'r': random """ #auto? if myturn=='a': #figure out whether the initial heap status is good for the Computer winning = self.is_winning_for_next() #whether to make a good decision smart = self.make_good_choice() #winning&smart or !winning&!smart makes the Computer start myturn = winning ^ smart #random? elif myturn=='r': myturn = random.choice([True, False]) #map c to False, p to True elif isinstance(myturn, str): myturn = myturn.lower() if myturn=='c': myturn = False elif myturn=='p': myturn = True else: raise ValueError(f'"myturn" has an unknown value: "{myturn}"') #create the "nextturn" cyclic iterator function self.nextturn=itertools.cycle(('Computer', 'Player')).__next__ #Player's turn needed? if myturn: #the cycle starts by the Computer, so get rid of it self.nextturn() #get the next player self.activeplayer = self.nextturn()
[docs] def set_error_rate(self, error_rate: ErrorRate_T ) -> None: """Set the required error rate, even during a game Args: error_rate: The error rate to be set from the next move on """ #validate the error rate v8.validate( name='error_rate', value=error_rate, instance_of=ErrorRateTypes ) if isinstance(error_rate, ErrorRate): for player in ErrorRate._fields: v8.validate( name=f'error_rate.{player}', value=getattr(error_rate, player), instance_of=int, custom=v8.validation_lib.comparables.between(0, 100) ) else: v8.validate( name='error_rate', value=error_rate, instance_of=int, custom=v8.validation_lib.comparables.between(0, 100) ) self.error_rate = error_rate
[docs] def do_move(self, move: Move ) -> None: """Remove given number of coins from a given heap Before doing the coin removal, check for illegal moves and report issues. After the given number of coins have been removed from a given heap, swap the active player. Args: move: The designation of the heap and the number of coins to be removed from that heap. Heap designation can be a letter or a number (starting from 0). """ #heap designation letter? if isinstance(move.heapdesig, str): if len(move.heapdesig) != 1: raise ValueError( f'Wrong heap designation ({move.heapdesig})' ) heapnumber = ord(move.heapdesig.upper()) - 65 if heapnumber < 0: raise ValueError( f'Wrong heap designation ({move.heapdesig})' ) #heap designation number? elif isinstance(move.heapdesig, int): heapnumber = move.heapdesig else: raise ValueError( f'Wrong heap designation type ({type(move.heapdesig)})' ) if len(self.heaps)-1 < heapnumber: raise ValueError( f'Wrong heap letter ({chr(heapnumber+65)}), there are ' f'A-{chr(len(self.heaps)-1+65)} heaps only' ) if self.heaps[heapnumber] < move.removecount: raise ValueError( f'Heap({chr(heapnumber+65)}) only has ' f'{self.heaps[heapnumber]} coin(s), ' f'cannot remove {move.removecount}' ) #reduce the required heap self.heaps[heapnumber] -= move.removecount #get the next player self.activeplayer = self.nextturn()
[docs] def get_heapstatus(self) -> str: """Get the heap status in a formatted string Returns: The number of coins per heap. Also header at the start. """ status = '' #at the start? if self.activeplayer == 'start': #start with the heading line status = ' '.join( [f' {chr(65+h)}' for h in range(len(self.heaps))] ) + '\n' #list the coins in the heaps, single-digit numbers padded with space status += ' '.join([f'{str(h):>2}' for h in self.heaps]) return status
[docs] def game_end(self) -> bool: """Identify whether the game ended, i.e. all heaps are empty Simply add up all the coins and return the boolean negate of the sum. Returns: The flag to indicate game end. """ return not sum(self.heaps)