"""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)