PyConnect-4/cli/cli_game.py
2025-10-28 16:27:24 +13:00

509 lines
16 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from colours import Colours as C
import random
import json
# Helper functions
# Clears the console by printing an ANSI sequence
def clear():
print(end='\033[2J\033[1;1H',flush=True)
# Returns the coloured version of a tile from the raw 'R' or 'Y'
def colourTile(tile):
try:
with open("settings.json", "r") as f:
settings = json.load(f)
mode = settings.get("display_mode", "coloured_text")
except (FileNotFoundError, json.JSONDecodeError):
mode = "coloured_text"
if mode == "coloured_text":
if tile == 'R':
return f"{C.BOLD}{C.RED} R {C.END}"
elif tile == 'Y':
return f"{C.BOLD}{C.YELLOW} Y {C.END}"
elif tile == 'r':
return f"{C.BOLD}{C.LIGHT_GREEN} R {C.END}"
elif tile == 'y':
return f"{C.BOLD}{C.LIGHT_GREEN} Y {C.END}"
else:
return " "
elif mode == "coloured_background":
if tile == 'R':
return f"{C.BG_RED} {C.END}"
elif tile == 'Y':
return f"{C.BG_LIGHT_YELLOW} {C.END}"
elif tile == 'r':
return f"{C.BG_LIGHT_GREEN}{C.BOLD} R {C.END}"
elif tile == 'y':
return f"{C.BG_LIGHT_GREEN}{C.BOLD} Y {C.END}"
else:
return " "
elif mode == "emojis":
if tile == 'R':
return "🔴"
elif tile == 'r':
return ""
elif tile == 'Y':
return "🟡"
elif tile == 'y':
return "⚠️"
else:
return " "
return tile
# Prints the board in a clean, easily understandable manner.
def printBoard(board):
try:
with open("settings.json", "r") as f:
settings = json.load(f)
mode = settings.get("display_mode", "coloured_text")
except (FileNotFoundError, json.JSONDecodeError):
mode = "coloured_text"
rows = []
for i in range(6):
row = ""
for column in board:
row += f"{C.BOLD}|{C.END}{colourTile(column[i])}"
row += f"{C.BOLD}|{C.END}"
rows.append(row)
rows.reverse()
if mode == "emojis":
top = f""" {C.BOLD}CONNECT FOUR
======================{C.END}"""
bottom = f"{C.BOLD}=1⃣=2⃣=3⃣=4⃣=5⃣=6⃣=7⃣={C.END}"
else:
top = f""" {C.BOLD}CONNECT FOUR
============================={C.END}"""
bottom = f"{C.BOLD}==1===2===3===4===5===6===7=={C.END}"
print(f"{top}\n{'\n'.join(rows)}\n{bottom}")
# Prompts you for a column until you enter a valid one
def getColInput(prompt, board=None):
while True:
inp = input(prompt)
try:
inp = int(inp)
if not 1 <= inp <= 7:
raise ValueError
return inp
except ValueError:
clear()
if board:
printBoard(board)
print("Only integers 1-7 allowed")
# Checks for a 4-in-a-row
def checkWin(board, player):
rows, cols = (6, 7)
winCount = 4
# Horizontal
for row in range(rows):
for col in range(cols - winCount + 1):
if all(board[col + i][row] == player for i in range(winCount)):
return [(col + i, row) for i in range(winCount)]
# Vertical
for col in range(cols):
for row in range(rows - winCount + 1):
if all(board[col][row + i] == player for i in range(winCount)):
return [(col, row + i) for i in range(winCount)]
# Diagonal
for col in range(cols - winCount + 1):
for row in range(rows - winCount + 1):
if all(board[col + i][row + i] == player for i in range(winCount)):
return [(col + i, row + i) for i in range(winCount)]
for col in range(cols - winCount + 1):
for row in range(winCount - 1, rows):
if all(board[col + i][row - i] == player for i in range(winCount)):
return [(col + i, row - i) for i in range(winCount)]
# Checks if the board is full
def checkFull(board):
if all('O' not in col for col in board):
return True
return False
# Checks if the game is a draw, win for red or win for yellow (for the CPU)
def isTerminalNode(board):
if checkWin(board, 'R'):
return "WinX"
elif checkWin(board, 'Y'):
return "WinY"
elif checkFull(board):
return "Draw"
return False
# Evaluates a board position
def evalWindow(window, player):
opponent = 'Y' if player == 'R' else 'R'
player_count = window.count(player)
opponent_count = window.count(opponent)
empty_count = window.count('O')
score = 0
if player_count == 4:
score += 100000 # guaranteed win
elif player_count == 3 and empty_count == 1:
score += 100
elif player_count == 2 and empty_count == 2:
score += 10
if opponent_count == 3 and empty_count == 1:
score -= 120 # prioritize blocking opponent
return score
# Provides a score for each position, using the above evalWindow function
def evalPositionForPlayer(board, player):
score = 0
# Score center column
center_col = len(board) // 2
center_array = board[center_col]
center_count = center_array.count(player)
score += center_count * 3
# Score Horizontal
for row in range(len(board[0])):
row_array = [board[col][row] for col in range(len(board))]
for col in range(len(board) - 3):
window = row_array[col:col+4]
score += evalWindow(window, player)
# Score Vertical
for col in range(len(board)):
col_array = board[col]
for row in range(len(board[col]) - 3):
window = col_array[row:row+4]
score += evalWindow(window, player)
# Score positive diagonals
for col in range(len(board) - 3):
for row in range(len(board[0]) - 3):
window = [board[col+i][row+i] for i in range(4)]
score += evalWindow(window, player)
# Score negative diagonals
for col in range(len(board) - 3):
for row in range(3, len(board[0])):
window = [board[col+i][row-i] for i in range(4)]
score += evalWindow(window, player)
return score
# Returns the position evaluation
# Where a positive value means red is winning and a negative value means yellow is winning
def evalPosition(board):
red_score = evalPositionForPlayer(board, 'R')
yellow_score = evalPositionForPlayer(board, 'Y')
return red_score - yellow_score
# The minimax algorithm that the computer uses
def minimax(board, depth, alpha, beta, maximisingPlayer):
isTerminal = isTerminalNode(board)
if isTerminal:
if isTerminal == "WinX": # Red wins
return float('inf')
elif isTerminal == "WinY": # Yellow wins
return float('-inf')
elif isTerminal == "Draw":
return 0
allowedMoves = [i for i, col in enumerate(board) if 'O' in col]
allowedMoves.sort(key=lambda col: abs(3 - col)) # try center → edges
if depth == 0 or not allowedMoves:
return evalPosition(board)
if maximisingPlayer:
maxEval = float('-inf')
for move in allowedMoves:
newPosition = [col.copy() for col in board]
tile = newPosition[move].index("O")
newPosition[move][tile] = 'R'
evaluation = minimax(newPosition, depth - 1, alpha, beta, False)
maxEval = max(maxEval, evaluation)
alpha = max(alpha, evaluation)
if beta <= alpha:
break
return maxEval
else:
minEval = float('inf')
for move in allowedMoves:
newPosition = [col.copy() for col in board]
tile = newPosition[move].index("O")
newPosition[move][tile] = 'Y'
evaluation = minimax(newPosition, depth - 1, alpha, beta, True)
minEval = min(minEval, evaluation)
beta = min(beta, evaluation)
if beta <= alpha:
break
return minEval
# Player move providers
# Gets the human inputs locally
def local_move_provider(player, board):
# return random.choice([i for i, col in enumerate(board) if 'O' in col])
col = getColInput(f"{colourTile(player)} where do you want to drop your tile? 1-7.\n>>> ", board) - 1
return col
from multiprocessing import Pool
# Helper for multiprocessing
def evaluate_move(args):
move, board, player, depth, maximising = args
newBoard = [col.copy() for col in board]
tile = newBoard[move].index('O')
newBoard[move][tile] = player
score = minimax(newBoard, depth - 1, float('-inf'), float('inf'), not maximising)
return move, score
# Gets the CPUs move
def cpu_move_provider(player, board):
allowedMoves = [i for i, col in enumerate(board) if 'O' in col]
# Move ordering
allowedMoves.sort(key=lambda col: abs(3 - col))
try:
with open("settings.json", "r") as f:
settings = json.load(f)
search_depth = settings.get("cpu_search_depth", 5)
except (FileNotFoundError, json.JSONDecodeError):
search_depth = 5
maximising = (player == 'R')
# Prepare arguments for pool
args_list = [(move, board, player, search_depth, maximising) for move in allowedMoves]
with Pool() as pool:
results = pool.map(evaluate_move, args_list)
if player == 'R':
return max(results, key=lambda x: x[1])[0]
else:
return min(results, key=lambda x: x[1])[0]
# Main game loop
def play_game(player1_get_move, player2_get_move):
board = [['O'] * 6 for _ in range(7)]
player = 'R'
while True:
clear()
printBoard(board)
if player == 'R':
col = player1_get_move(player, board)
else:
col = player2_get_move(player, board)
try:
tile = board[col].index("O")
except ValueError:
continue
board[col][tile] = player
winPositions = checkWin(board, player)
if winPositions:
for x, y in winPositions:
board[x][y] = board[x][y].lower()
clear()
printBoard(board)
print(f"{colourTile(player)} won!")
input("Press ENTER to return to the menu.")
break
if checkFull(board):
clear()
printBoard(board)
print("Its a draw!")
input("Press ENTER to return to the menu.")
break
player = 'Y' if player == 'R' else 'R'
# Modes
# Player vs Player on the local machine
def play_local_pvp():
play_game(local_move_provider, local_move_provider)
# Player vs Player LAN stubs
def play_lan_server():
print("PvP LAN is in maintenance due to exploits.!")
input("Press ENTER to return to menu...")
return
def play_lan_client():
print("PvP LAN is in maintenance due to exploits.!")
input("Press ENTER to return to menu...")
return
# Player vs Computer
def play_vs_computer():
while True:
inp = input("Do you want to play as red or yellow? ").lower()
try:
if not inp in ["r", "red", "y", "yellow"]:
raise ValueError
break
except ValueError:
print("ENTER 'r', 'red', 'y' or 'yellow'.")
if inp in ["r", "red"]:
play_game(local_move_provider, cpu_move_provider)
elif inp in ["y", "yellow"]:
play_game(cpu_move_provider, local_move_provider)
# Settings menu
def edit_settings():
settings_file = "settings.json"
# Load the default settings if no settings.json exists
default_settings = {
"display_mode": "coloured_text",
"cpu_search_depth": 5
}
# Try to load the settings from settings.json
try:
with open(settings_file, "r") as f:
try:
settings = json.load(f)
except json.JSONDecodeError:
settings = default_settings.copy()
except:
settings = default_settings.copy()
# Store a copy of the settings for detecting unsaved changes
original_settings = settings.copy()
def save_settings():
with open(settings_file, "w") as f:
json.dump(settings, f, indent=4)
print("Settings saved.")
input("Press ENTER to return to main menu...")
while True:
clear()
print("=== Settings Menu ===")
print("1. Display Mode")
print("2. CPU Search Depth")
print("--------------------")
print("S. Save and Exit")
print("E. Exit without Saving")
print()
print(f"Current Settings: {settings}")
choice = input("Choose a setting to edit, or Save/Exit: ").strip().lower()
if choice == "1":
# Display Mode menu
while True:
clear()
print("=== Display Mode ===")
modes = [
("coloured_text", "Coloured Text"),
("coloured_background", "Coloured Background"),
("emojis", "Emojis")
]
for i, (key, label) in enumerate(modes, start=1):
if settings["display_mode"] == key:
print(f"{i}. {C.BOLD}{label}{C.END}")
else:
print(f"{i}. {label}")
print("B. Go Back")
sub_choice = input("Choose a display mode: ").strip().lower()
if sub_choice == "b":
break
elif sub_choice in [str(i) for i in range(1, len(modes) + 1)]:
settings["display_mode"] = modes[int(sub_choice) - 1][0]
print(f"Display mode set to {settings['display_mode']}")
else:
input("Invalid choice. Press ENTER to try again...")
elif choice == "2":
# CPU Search Depth menu
while True:
clear()
print("=== CPU Search Depth ===")
print(f"Depth: {C.BOLD}{settings["cpu_search_depth"]}{C.END}")
print("B. Go Back")
sub_choice = input("Choose a value 1-9, or the use + / - keys: ").strip()
if sub_choice.lower() == 'b':
break
elif sub_choice in ["1","2","3","4","5","6","7","8","9"]:
settings["cpu_search_depth"] = int(sub_choice)
elif sub_choice in ['+', '-']:
settings["cpu_search_depth"] = eval(f"{settings["cpu_search_depth"]} {sub_choice}1")
if settings["cpu_search_depth"] > 9:
settings["cpu_search_depth"] = 9
elif settings["cpu_search_depth"] < 1:
settings["cpu_search_depth"] = 1
else:
input("Invalid choice. Press ENTER to try again...")
elif choice == "save" or choice == "s":
save_settings()
return
elif choice == "exit" or choice == "e":
if settings != original_settings:
confirm = input("You have unsaved changes. Exit without saving? (y/n): ").lower()
if confirm == "y":
return
else:
return
else:
input("Invalid choice. Press ENTER to try again...")
# Main menu loop
while True:
clear()
print("How do you want to play?")
print("1. Player vs Player (same device)")
print("2. Player vs Player (LAN)")
print("3. Player vs CPU")
print("4. Edit settings")
print("5. Quit")
choice = input("Choose 1-4: ").strip()
if choice == "1":
play_local_pvp()
elif choice == "2":
if input("Are you hosting? (y/n): ").lower() == "y":
play_lan_server()
else:
play_lan_client()
elif choice == "3":
play_vs_computer()
elif choice == "4":
edit_settings()
elif choice == "5":
break
else:
input("Invalid choice. Press ENTER to try again...")