Skip to content

Instantly share code, notes, and snippets.

@EdNutting
Created December 11, 2018 13:31
Show Gist options
  • Save EdNutting/907f455ad4481f50a5c3faee6096155b to your computer and use it in GitHub Desktop.
Save EdNutting/907f455ad4481f50a5c3faee6096155b to your computer and use it in GitHub Desktop.
Simple version of Peg Solitaire in Python
import os, sys
from enum import Enum
# Enumerables used to define machine-checkable values
# They also make the code more readable and flexible
# Stores possible inputs
class InputType(Enum):
QUIT = -1 # 'q' - Immediately quit the game
NONE = 0 # Blank input - indicates nothing
RESTART = 1 # 'r' - Immediately restart the game
INVALID = 2 # Invalid input - indicates user error
UNDO = 3 # 'u' - Undo the last move
VALUE = 4 # A valid value for X/Y/Direction
BACK = 5 # 'b' - Go back to previous entry step (X/Y/Dir)
# Stores current state of the game
class GameState(Enum):
PLAYING = 0 # The game continues...
LOSE = 1 # The game has been lost (undo still possible)
WIN = 2 # The game has been won (undo still possible)
QUIT = 3 # Quit the game at the next opportunity
class LocationState(Enum):
INVALID = -1 # A peg cannot be moved to an 'invalid' location
HOLE = 0
PEG = 1
class InputState(Enum):
X = 0 # Currently waiting for a valid X coordinate
Y = 1 # Currently waiting for a valid Y coordinate
DIR = 2 # Currently waiting for a valid direction
RESTART = 3 # Currently waiting for whether a new game should be started
class Direction(Enum):
NORTH = 0
SOUTH = 1
EAST = 2
WEST = 3
input_state = None # Input state
state = None # Game state
board = None # Board state
# Note: The board is padded by two rows/columns on all sides
# This eliminates the need for array bounds checks throughout
# the code.
move_state = (None, None, None) # Validated inputted move
past_boards = [] # Undo history for current game (stack)
# Create an entirely empty (all-invalid) board
def initEmptyBoard():
board = []
for i in range(0, 11):
board.append([])
for j in range(0, 11):
board[i].append(LocationState.INVALID)
return board
# Given an existing board, set up the standard starting state
def initStartingBoard(board):
for i in range(2, 9):
if i < 4 or i > 6:
for j in range(4, 7):
board[i][j] = LocationState.PEG
else:
for j in range(2, 9):
board[i][j] = LocationState.PEG
board[5][5] = LocationState.HOLE
# Given one of the shorthand representations used in Winning/Losing board
# convert it to the proper Enumerable types
def convertBoard(board):
for i in range(0, 11):
for j in range(0, 11):
if board[i][j] == -1:
board[i][j] = LocationState.INVALID
elif board[i][j] == 0:
board[i][j] = LocationState.HOLE
elif board[i][j] == 1:
board[i][j] = LocationState.PEG
# Initialise a trivially winnable board
def initWinningBoard():
board = [[-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1],
[-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1],
[-1, -1, -1, -1, 0, 0, 0, -1, -1, -1, -1],
[-1, -1, -1, -1, 0, 0, 0, -1, -1, -1, -1],
[-1, -1, 0, 0, 0, 0, 0, 0, 0, -1, -1],
[-1, -1, 0, 1, 1, 0, 0, 0, 0, -1, -1],
[-1, -1, 0, 0, 0, 0, 0, 0, 0, -1, -1],
[-1, -1, -1, -1, 0, 0, 0, -1, -1, -1, -1],
[-1, -1, -1, -1, 0, 0, 0, -1, -1, -1, -1],
[-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1],
[-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1],
]
convertBoard(board)
return board
# Initialise a trivially losing board
def initLosingBoard():
board = [[-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1],
[-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1],
[-1, -1, -1, -1, 0, 0, 0, -1, -1, -1, -1],
[-1, -1, -1, -1, 0, 0, 0, -1, -1, -1, -1],
[-1, -1, 0, 0, 0, 0, 0, 0, 0, -1, -1],
[-1, -1, 0, 1, 1, 0, 0, 1, 0, -1, -1],
[-1, -1, 0, 0, 0, 0, 0, 0, 0, -1, -1],
[-1, -1, -1, -1, 0, 0, 0, -1, -1, -1, -1],
[-1, -1, -1, -1, 0, 0, 0, -1, -1, -1, -1],
[-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1],
[-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1],
]
convertBoard(board)
return board
# Clone the contents of a board
# Note: Normal assignment (x = y) with arrays uses 'referential'
# assignment - the reference to the array is copied, not the
# contents of the array. In this case, we have an array of
# arrays - so we have to create a new array of arrays with the
# values copied instead of the references copied.
def cloneBoard(board):
board_clone = []
for i in range(0, 11):
board_clone.append(board[i].copy())
return board_clone
# Make code more readable later on by creating helper functions
# that have easy-to-read (from left-to-right) names
def isHole(x, y):
global board
return board[y][x] == LocationState.HOLE
def isPeg(x, y):
global board
return board[y][x] == LocationState.PEG
# Initialise a fresh game state
def init():
global state, past_boards, board, input_state, move_state
past_boards = []
move_state = (None, None, None)
state = GameState.PLAYING
input_state = InputState.X
board = initEmptyBoard()
initStartingBoard(board)
# Uncomment one of these lines for testing the final stages
# of the game
# board = initWinningBoard()
# board = initLosingBoard()
# Read input from the user according to the current input state
# Note: Does not change the current input state
# Note: Minimal sanitisation of the data to get it into the right
# format for the evaluation code. This will pass on invalid
# input.
def readInput():
global input_state
# Print relevant messages based on input state
if input_state == InputState.X:
print("X: ", end = '')
elif input_state == InputState.Y:
print("Y: ", end = '')
elif input_state == InputState.DIR:
print("N/S/E/W: ", end = '')
elif input_state == InputState.RESTART:
print("New game? Y/N: ", end = '')
else:
print("Error: Unknown input state.")
# Flushing normally happens after a newline
# but I stopped the newlines above so that
# the user inputs on the same line as the instructions
# So flush here to make the text appear
sys.stdout.flush()
# Read the text input from the user
input = sys.stdin.readline()
# Sanitise the text input - lower case and remove whitespace
input = input.lower().strip()
# Set up a default result
result = (InputType.NONE, None)
# Quit, Undo, Restart and Back inputs can happen at any time
if input == "q" or input == "quit":
result = (InputType.QUIT, None)
elif input == "u" or input == "undo":
result = (InputType.UNDO, None)
elif input == "r" or input == "restart":
result = (InputType.RESTART, None)
elif input == "b" or input == "back":
result = (InputType.BACK, None)
# Input for starting a new game or not
elif input_state == InputState.RESTART:
if input == "y" or input == "yes":
result = (InputType.VALUE, True)
elif input == "n" or input == "no":
result = (InputType.VALUE, False)
else:
result = (InputType.INVALID, input)
# Input for X or Y values
elif input_state == InputState.X or input_state == InputState.Y:
# Try to get an integer, if we fail, it's invalid input
try:
value = int(input)
result = (InputType.VALUE, value)
except:
result = (InputType.INVALID, input)
elif input_state == InputState.DIR:
# Try to get a valid direction, if we fail, it's invalid input
if input == "n" or input == "north":
result = (InputType.VALUE, Direction.NORTH)
elif input == "s" or input == "south":
result = (InputType.VALUE, Direction.SOUTH)
elif input == "e" or input == "east":
result = (InputType.VALUE, Direction.EAST)
elif input == "w" or input == "west":
result = (InputType.VALUE, Direction.WEST)
else:
result = (InputType.INVALID, input)
else:
# Possibly may have missed something in rapid development
# so add a fallback plan just in case!
print("Error: Unknown input state!")
result = (InputType.NONE, None)
return result
# Compute the cell that is 'dist' away from (x, y) in direction 'd'
def computeLocation(x, y, d, dist = 2):
if d == Direction.NORTH:
return (x, y - dist)
elif d == Direction.SOUTH:
return (x, y + dist)
elif d == Direction.EAST:
return (x + dist, y)
elif d == Direction.WEST:
return (x - dist, y)
else:
return (x, y)
# Determine whether a given location and direction
# constitute a valid move or not
def isValidMove(x, y, d):
# Check that the location 1 away is a peg
# and 2 away is a hole
l1 = computeLocation(x, y, d, dist = 1)
l2 = computeLocation(x, y, d, dist = 2)
return isPeg(x, y) and isPeg(l1[0], l1[1]) and isHole(l2[0], l2[1])
# Determine whether a given location has a valid move
# i.e. contains a peg and there is a peg to jump over into a hole
def hasMove(x, y):
# Early escape - minor optimisation
if not isPeg(x, y):
return False
ok = False
# For each possible direction
for d in [Direction.NORTH, Direction.SOUTH,
Direction.EAST , Direction.WEST]:
# If any direction has a valid move then we are 'ok'
# hence 'or' together the results
ok = ok or isValidMove(x, y, d)
return ok
def oppositeDirection(d):
if d == Direction.NORTH:
return Direction.SOUTH
elif d == Direction.SOUTH:
return Direction.NORTH
elif d == Direction.EAST:
return Direction.WEST
elif d == Direction.WEST:
return Direction.EAST
# Determine whether the player could move into (x, y) from their
# currently selected move state
def couldMoveHere(x, y):
global move_state
for d in [Direction.NORTH, Direction.SOUTH,
Direction.EAST , Direction.WEST]:
l = computeLocation(x, y, d, dist = 2)
if (move_state[0] == None or l[0] == (move_state[0] + 2)) and \
(move_state[1] == None or l[1] == (move_state[1] + 2)) and \
isValidMove(l[0], l[1], oppositeDirection(d)):
return True
return False
# Print the current move state in a human-readable way
def printMove():
global move_state
print("Current move: " + str(move_state[0]) + ", " + str(move_state[1]))
# Print the current board with X/Y coordinates in a human-readable way
# Note: Use standard unicode characters for circles (I Googled for them)
# to nicely represent the pegs and holes
# (https://unicode-search.net/unicode-namesearch.pl?term=CIRCLE)
def printBoard(board):
# Print X-coords across the top
print(" ", end = '')
for i in range(2, 9):
print(str(i - 2) + " ", end = '')
print()
for i in range(2, 9):
# Print Y-coord for current row
print(str(i - 2) + " ", end = '')
# Print the current row of the board
for j in range(2, 9):
if board[i][j] == LocationState.INVALID:
print(" ", end = '')
elif isHole(j, i):
if couldMoveHere(j, i):
print("◌ ", end = '')
else:
print("○ ", end = '')
elif isPeg(j, i):
print("● ", end = '')
# Go to next row
print()
# Undo a move by restoring the board, game, move and input states
def undoMove():
global state, past_boards, board, input_state, move_state
if len(past_boards) > 0:
board = past_boards.pop()
state = GameState.PLAYING
move_state = (None, None, None)
input_state = InputState.X
else:
print("No more moves to undo!")
pass
# Evaluate a move
# Note: This assumes the move is valid
# Validation is performed separately before this is called
def evalMove():
global state, past_boards, board, input_state, move_state
# Store a copy of the current board for the undo history
past_boards.append(cloneBoard(board))
# Extract x, y and direction values
x = move_state[0] + 2
y = move_state[1] + 2
d = move_state[2]
# Compute new locations for holes and pegs
newHoleLoc1 = computeLocation(x, y, d, dist = 1)
newHoleLoc2 = (x, y)
newPegLoc = computeLocation(x, y, d, dist = 2)
# Update the board state
board[newHoleLoc1[1]][newHoleLoc1[0]] = LocationState.HOLE
board[newHoleLoc2[1]][newHoleLoc2[0]] = LocationState.HOLE
board[newPegLoc[1]][newPegLoc[0]] = LocationState.PEG
# Determine how many pegs are left
# And if any valid moves exist
pegCount = 0
validMoves = False
for i in range(2, 9):
for j in range(2, 9):
if isPeg(j, i):
pegCount += 1
validMoves = validMoves or hasMove(j, i)
# Did the user win the game?
if pegCount == 1:
state = GameState.WIN
input_state = InputState.RESTART
# Did the user lose the game?
elif validMoves == 0:
state = GameState.LOSE
input_state = InputState.RESTART
# Check the currently inputted move state to see
# if it is going to be or is a valid move or not
# Note: +2 in this function is because the board
# is padded with 2 rows/columns of 'invalid'
# cells - this eliminates the needs for bounds
# checks when accessing the array which makes the
# code cleaner. We also use this technique for
# high performance computing code! (Scatter/gather)
def validateMoveState():
global state, board, input_state, move_state
# Extract x, y and direction variables
x = move_state[0]
y = move_state[1]
d = move_state[2]
# No input yet
if x == None:
return True
# X has been inputted
# - Check x is in range
# - Check at least one valid move exists in that column
elif y == None:
if x >= 0 and x < 7:
ok = False
for y in range(0, 7):
ok = ok or hasMove(x + 2, y + 2)
return ok
else:
return False
# X and Y have been inputted
# - Check x and y are in range
# - Check at least one valid move exists for this location
elif d == None:
if x >= 0 and x < 7 and y >= 0 and y < 7:
return hasMove(x + 2, y + 2)
else:
return False
# X, Y and Direction have been inputted
# - Check x and y are in range
# - Check this is a valid move to make
else:
if x >= 0 and x < 7 and y >= 0 and y < 7:
loc = computeLocation(x + 2, y + 2, d)
return isValidMove(x + 2, y + 2, d)
else:
return False
# Evaluate a step of the playing state of the game
def evalPlayingState(input_type, input_value):
global state, board, input_state, move_state
# Undo the previous move - can be asked for at any input stage
if input_type == InputType.UNDO:
undoMove()
# Go back to previous input stage
# Note: Clear out last inputted value as well
elif input_type == InputType.BACK:
if input_state == InputState.X:
input_state = InputState.X
move_state = (None, None, None)
elif input_state == InputState.Y:
input_state = InputState.X
move_state = (None, None, None)
elif input_state == InputState.DIR:
input_state = InputState.Y
move_state = (move_state[0], None, None)
# Consume an inputted value
elif input_type == InputType.VALUE:
# X coordinate
if input_state == InputState.X:
move_state = (input_value, None, None)
if (validateMoveState()):
input_state = InputState.Y
else:
move_state = (None, None, None)
print("Invalid location or no available moves.")
# Y coordinate
elif input_state == InputState.Y:
move_state = (move_state[0], input_value, None)
if (validateMoveState()):
input_state = InputState.DIR
else:
move_state = (move_state[0], None, None)
print("Invalid move or no available moves. Please select elsewhere or use 'b' to go back.")
# Direction
elif input_state == InputState.DIR:
move_state = (move_state[0], move_state[1], input_value)
if (validateMoveState()):
# If the whole thing is a valid move,
# evaluate the move and go to the next move (or whatever evalMove sets up)
input_state = InputState.X
evalMove()
move_state = (None, None, None)
else:
move_state = (move_state[0], move_state[1], None)
print("Invalid move. Please select elsewhere or use 'b' to go back.")
# Process the winning or losing states
# Basically: Wait to see if they want to undo, restart or quit
def evalEndOfGameState(input_type, input_value):
global state
if input_type == InputType.RESTART:
init()
if input_type == InputType.VALUE:
if input_value:
init()
else:
state = GameState.QUIT
elif input_type == InputType.UNDO:
undoMove()
else:
print("Invalid input '" + input_value + "'. Please enter 'yes', 'no', 'undo' or 'restart'.")
# Evaluate the game state
def evalState(input):
global state, board, input_state, move_state
# Extract the input type and value
input_type = input[0]
input_value = input[1]
# Quit, Invalid and Restart could happen at any input stage
if input_type == InputType.QUIT:
state = GameState.QUIT
return
elif input_type == InputType.INVALID:
print("Invalid input '" + input_value + "'. Please try again.")
return
elif input_type == InputType.RESTART:
init()
return
# Execute the state transitions
if state == GameState.PLAYING:
evalPlayingState(input_type, input_value)
elif state == GameState.LOSE:
evalEndOfGameState(input_type, input_value)
elif state == GameState.WIN:
evalEndOfGameState(input_type, input_value)
else:
# In case during rapid development we missed something...
print("Erm...unknown game state - the programmer of this game messed up!")
# Print output as appropriate for the current game state
def printOutput():
global state, board
print("")
if state == GameState.PLAYING:
printMove()
if state == GameState.LOSE:
print("You lost.")
if state == GameState.WIN:
print("You won!")
print()
printBoard(board)
print()
# The main read-eval-print loop (REPL)
# Note: The actual order given is print-read-eval (a rotation of REPL)
# This is so that a new game starts immediately when the app is first opened
def main():
global state
print ("Welcome to the game!")
init()
input = (InputType.NONE, None)
# Clear the console using a (Windows) system call
os.system('cls')
while (state != GameState.QUIT):
printOutput()
input = readInput()
# Clear the console using a (Windows) system call
os.system('cls')
evalState(input)
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment