"""Connect Four terminal game with AI (minimax + alpha-beta + heuristic), using Rich for display.""" import os import queue import random import threading import time from enum import Enum, auto from pathlib import Path import readchar from dotenv import load_dotenv from rich.console import Console, Group from rich.live import Live from rich.text import Text load_dotenv(Path(__file__).parent / ".env") # --- Configuration from .env --- LOOK_AHEAD = int(os.getenv("LOOK_AHEAD", "8")) BLUNDER_ENABLED = os.getenv("BLUNDER_ENABLED", "false").lower() == "true" BLUNDER_CHANCE = int(os.getenv("BLUNDER_CHANCE", "20")) DEMO_RESET_PAUSE = int(os.getenv("DEMO_RESET_PAUSE", "5")) IDLE_TIMEOUT = int(os.getenv("IDLE_TIMEOUT", "60")) MAX_GAME_LOG = int(os.getenv("MAX_GAME_LOG", "100")) GAMES_FILE = Path(__file__).parent / ".games.txt" COLS = 7 ROWS = 6 COL_ORDER = [3, 2, 4, 1, 5, 0, 6] # Box-drawing characters for the board frame DISC = "\u2b24" EMPTY = "\u25cb" H_LINE = "\u2500" V_LINE = "\u2502" TL = "\u250c" TR = "\u2510" BL = "\u2514" BR = "\u2518" T_DOWN = "\u252c" T_UP = "\u2534" T_RIGHT = "\u251c" T_LEFT = "\u2524" CROSS = "\u253c" console = Console() # Key constants - readchar uses escape sequences KEY_LEFT = readchar.key.LEFT if hasattr(readchar.key, "LEFT") else "\x1b[D" KEY_RIGHT = readchar.key.RIGHT if hasattr(readchar.key, "RIGHT") else "\x1b[C" KEY_UP = readchar.key.UP if hasattr(readchar.key, "UP") else "\x1b[A" KEY_DOWN = readchar.key.DOWN if hasattr(readchar.key, "DOWN") else "\x1b[B" KEY_ENTER = readchar.key.ENTER if hasattr(readchar.key, "ENTER") else "\r" CONFIRM_KEYS = {KEY_ENTER, " ", "\r", "\n"} class State(Enum): MENU = auto() PLAYING = auto() AI_TURN = auto() FINISHED_WIN = auto() FINISHED_DRAW = auto() DEMO = auto() def player_name(player: int) -> str: return "Yellow" if player == 1 else "Red" def player_style(player: int) -> str: return "bold yellow" if player == 1 else "bold red" def dim_player_style(player: int) -> str: return "dim yellow" if player == 1 else "dim red" # --- Board --- def make_board() -> list[list[int]]: return [[0] * ROWS for _ in range(COLS)] def get_first_empty_row(board: list[list[int]], col: int) -> int: for r in range(ROWS): if board[col][r] == 0: return r return -1 def is_board_full(board: list[list[int]]) -> bool: return all(board[c][ROWS - 1] != 0 for c in range(COLS)) def scan_board(board: list[list[int]]) -> tuple[int, list[tuple[int, int]]]: """Returns (winner, winning_positions). winner=0 if no winner.""" def check(c, r, dc, dr): p = board[c][r] if p != 0: positions = [(c + i * dc, r + i * dr) for i in range(4)] if all(board[cc][rr] == p for cc, rr in positions): return p, positions return 0, [] for r in range(ROWS): for c in range(COLS - 3): w, pos = check(c, r, 1, 0) if w: return w, pos for r in range(ROWS - 3): for c in range(COLS): w, pos = check(c, r, 0, 1) if w: return w, pos for r in range(ROWS - 3): for c in range(COLS - 3): w, pos = check(c, r, 1, 1) if w: return w, pos for r in range(3, ROWS): for c in range(COLS - 3): w, pos = check(c, r, 1, -1) if w: return w, pos return 0, [] # --- Display --- def render_board( board: list[list[int]], active_col: int = -1, current_player: int = 0, win_positions: list[tuple[int, int]] | None = None, flash_off: bool = False, is_draw_flash: bool = False, thinking_col: int = -1, thinking_bright: bool = False, ) -> Text: cell_w = 4 # width per cell including padding lines = Text() # Cursor row above the board cursor_line = Text(" ") for c in range(COLS): if thinking_col == c: style = player_style(current_player) if thinking_bright else dim_player_style(current_player) cursor_line.append(f" {DISC} ", style=style) elif c == active_col and current_player > 0: cursor_line.append(f" {DISC} ", style=player_style(current_player)) else: cursor_line.append(" ") lines.append_text(cursor_line) lines.append("\n") # Column numbers row num_line = Text(" ") for c in range(COLS): style = "bold white" if c == active_col else "dim" num_line.append(f" {c + 1} ", style=style) lines.append_text(num_line) lines.append("\n") # Top border top = Text(" ", style="bold blue") top.append(TL, style="bold blue") for c in range(COLS): top.append(H_LINE * (cell_w - 1), style="bold blue") top.append(T_DOWN if c < COLS - 1 else TR, style="bold blue") lines.append_text(top) lines.append("\n") # Board rows (top row of board = row 5, displayed first) for r in range(ROWS - 1, -1, -1): row_line = Text(" ", style="bold blue") for c in range(COLS): row_line.append(V_LINE, style="bold blue") val = board[c][r] if val == 0: row_line.append(f" {EMPTY} ", style="dim blue") else: is_win = win_positions and (c, r) in win_positions if flash_off and is_win: row_line.append(" ") elif is_draw_flash and flash_off: row_line.append(" ") elif not is_win and win_positions: row_line.append(f" {DISC} ", style=dim_player_style(val)) else: row_line.append(f" {DISC} ", style=player_style(val)) row_line.append(V_LINE, style="bold blue") lines.append_text(row_line) lines.append("\n") # Row separator or bottom border if r > 0: sep = Text(" ", style="bold blue") sep.append(T_RIGHT, style="bold blue") for c in range(COLS): sep.append(H_LINE * (cell_w - 1), style="bold blue") sep.append(CROSS if c < COLS - 1 else T_LEFT, style="bold blue") lines.append_text(sep) lines.append("\n") # Bottom border bot = Text(" ", style="bold blue") bot.append(BL, style="bold blue") for c in range(COLS): bot.append(H_LINE * (cell_w - 1), style="bold blue") bot.append(T_UP if c < COLS - 1 else BR, style="bold blue") lines.append_text(bot) lines.append("\n") return lines def render_menu(menu_mode: int) -> Text: items = ["1P Yellow (you start)", "1P Red (AI starts)", "Multiplayer"] lines = ["\n [bold blue]Connect Four[/bold blue]\n"] for i, item in enumerate(items): marker = " \u25b6 " if i == menu_mode else " " style = "bold yellow" if i == 0 else "bold red" if i == 1 else "bold blue" if i == menu_mode: lines.append(f"[{style}]{marker}{item}[/{style}]") else: lines.append(f"[dim]{marker}{item}[/dim]") lines.append("\n [dim]Up/Down to select, Space/Enter to start, Q to quit[/dim]\n") return Text.from_markup("\n".join(lines)) # --- Game log --- def load_game_log() -> list[dict]: if not GAMES_FILE.exists(): return [] games = [] for line in GAMES_FILE.read_text().splitlines(): line = line.strip() if not line: continue parts = line.split(":", 3) if len(parts) == 4: games.append({ "type": parts[0], "level": parts[1], "winner": parts[2], "moves": parts[3], }) return games[-MAX_GAME_LOG:] def save_game_log(games: list[dict]): with GAMES_FILE.open("w") as f: for g in games: f.write(f"{g['type']}:{g['level']}:{g['winner']}:{g['moves']}\n") def log_game(games: list[dict], game_menu_mode: int, level: int, winner: int, moves: str) -> list[dict]: game_type = "Y" if game_menu_mode == 0 else "R" if game_menu_mode == 1 else "2" win_char = "Y" if winner == 1 else "R" if winner == 2 else "D" entry = {"type": game_type, "level": str(level), "winner": win_char, "moves": moves} games.append(entry) games = games[-MAX_GAME_LOG:] save_game_log(games) return games # --- AI --- def evaluate_board(board: list[list[int]], ai_p: int, hu_p: int) -> int: score = 0 ai_threats = 0 hu_threats = 0 # Center column bonus for r in range(ROWS): if board[3][r] == ai_p: score += 3 elif board[3][r] == hu_p: score -= 3 # Score a window of 4 cells by piece counts def score_window(c: int, r: int, dc: int, dr: int) -> int: nonlocal ai_threats, hu_threats ai, hu, empty_c, empty_r = 0, 0, -1, -1 for i in range(4): cc = c + i * dc rr = r + i * dr v = board[cc][rr] if v == ai_p: ai += 1 elif v == hu_p: hu += 1 else: empty_c, empty_r = cc, rr if ai > 0 and hu > 0: return 0 if ai == 3: ai_threats += 1 playable = empty_r == 0 or board[empty_c][empty_r - 1] != 0 return 100 if playable else 40 if ai == 2: return 5 if hu == 3: hu_threats += 1 playable = empty_r == 0 or board[empty_c][empty_r - 1] != 0 return -100 if playable else -40 if hu == 2: return -5 return 0 # Horizontal for r in range(ROWS): for c in range(COLS - 3): score += score_window(c, r, 1, 0) # Vertical for r in range(ROWS - 3): for c in range(COLS): score += score_window(c, r, 0, 1) # Diagonal up-right for r in range(ROWS - 3): for c in range(COLS - 3): score += score_window(c, r, 1, 1) # Diagonal down-right for r in range(3, ROWS): for c in range(COLS - 3): score += score_window(c, r, 1, -1) # Fork bonus: multiple threats are disproportionately dangerous if ai_threats >= 2: score += 200 if hu_threats >= 2: score -= 200 return score def minimax( board: list[list[int]], depth: int, alpha: int, beta: int, is_max: bool, ai_p: int, hu_p: int, ) -> int: winner, _ = scan_board(board) if winner == ai_p: return 1000 + depth if winner == hu_p: return -1000 - depth if depth == 0 or is_board_full(board): return evaluate_board(board, ai_p, hu_p) best = -10000 if is_max else 10000 for c in COL_ORDER: r = get_first_empty_row(board, c) if r != -1: board[c][r] = ai_p if is_max else hu_p score = minimax(board, depth - 1, alpha, beta, not is_max, ai_p, hu_p) board[c][r] = 0 if is_max: if score > best: best = score if best > alpha: alpha = best else: if score < best: best = score if best < beta: beta = best if beta <= alpha: break return best def perform_ai_move( board: list[list[int]], ai_p: int, look_ahead: int, is_demo: bool = False, demo_ply: int = 4, ) -> int: hu_p = 2 if ai_p == 1 else 1 ply = demo_ply if is_demo else look_ahead # Phase 1a: check ALL columns for instant AI win for c in range(COLS): r = get_first_empty_row(board, c) if r != -1: board[c][r] = ai_p if scan_board(board)[0] == ai_p: board[c][r] = 0 return c board[c][r] = 0 # Phase 1b: check ALL columns for opponent block for c in range(COLS): r = get_first_empty_row(board, c) if r != -1: board[c][r] = hu_p if scan_board(board)[0] == hu_p: board[c][r] = 0 return c board[c][r] = 0 # Phase 2: blunder if not is_demo and BLUNDER_ENABLED and random.randint(0, 99) < BLUNDER_CHANCE: valid = [c for c in range(COLS) if get_first_empty_row(board, c) != -1] return random.choice(valid) # Phase 3: minimax best_score = -30000 best_col = 3 for c in COL_ORDER: r = get_first_empty_row(board, c) if r != -1: board[c][r] = ai_p score = minimax(board, ply, -30000, 30000, False, ai_p, hu_p) board[c][r] = 0 if score > best_score: best_score = score best_col = c return best_col def randomize_demo_plies() -> tuple[int, int]: strong = random.randint(4, 5) weak = random.randint(2, 3) if random.randint(0, 1) == 0: return strong, weak return weak, strong # --- Input (cross-platform, non-blocking via thread) --- _key_queue: queue.Queue[str] = queue.Queue() _input_stop = threading.Event() def _input_thread(): """Background thread that reads keys and puts them on the queue.""" while not _input_stop.is_set(): try: key = readchar.readkey() _key_queue.put(key) except Exception: break def read_key() -> str | None: """Non-blocking key read from the queue.""" try: return _key_queue.get_nowait() except queue.Empty: return None # --- Main game loop --- def main(): console.clear() game_state = State.MENU board = make_board() menu_mode = 0 current_player = 1 active_col = 3 winner_player = 0 win_positions: list[tuple[int, int]] = [] current_moves = "" game_menu_mode = 0 game_level = LOOK_AHEAD games = load_game_log() demo_ply = (4, 4) last_activity = time.time() demo_reset_timer = 0.0 flash_toggle = True last_flash = 0.0 def reset(): nonlocal board, winner_player, win_positions, current_moves board = make_board() winner_player = 0 win_positions = [] current_moves = "" def check_game_end() -> bool: nonlocal winner_player, win_positions, game_state, games, demo_reset_timer, last_activity winner_player, win_positions = scan_board(board) won = winner_player != 0 draw = not won and is_board_full(board) if not won and not draw: return False if game_state != State.DEMO: games = log_game(games, game_menu_mode, game_level, winner_player if won else 0, current_moves) game_state = State.FINISHED_WIN if won else State.FINISHED_DRAW demo_reset_timer = time.time() last_activity = time.time() return True # Start input thread input_thread = threading.Thread(target=_input_thread, daemon=True) input_thread.start() try: with Live(render_menu(menu_mode), console=console, refresh_per_second=10, screen=True) as live: while True: key = read_key() # Quit if key in ("q", "Q"): break # --- MENU --- if game_state == State.MENU: if key in (KEY_UP,): menu_mode = (menu_mode - 1) % 3 last_activity = time.time() elif key in (KEY_DOWN,): menu_mode = (menu_mode + 1) % 3 last_activity = time.time() elif key in CONFIRM_KEYS: reset() game_menu_mode = menu_mode game_level = LOOK_AHEAD current_player = 1 active_col = 3 if menu_mode == 1: game_state = State.AI_TURN else: game_state = State.PLAYING last_activity = time.time() if game_state == State.MENU: live.update(render_menu(menu_mode)) time.sleep(0.05) continue # --- Interrupt: return to menu from finished/demo --- if game_state in (State.FINISHED_WIN, State.FINISHED_DRAW, State.DEMO) and key is not None: reset() game_state = State.MENU menu_mode = 0 last_activity = time.time() live.update(render_menu(menu_mode)) time.sleep(0.2) continue # --- Idle timeout: enter demo --- if game_state not in (State.DEMO, State.FINISHED_WIN, State.FINISHED_DRAW): if time.time() - last_activity > IDLE_TIMEOUT: reset() demo_ply = randomize_demo_plies() game_state = State.DEMO current_player = 1 # --- PLAYING --- if game_state == State.PLAYING: if key in (KEY_LEFT,): active_col = max(0, active_col - 1) last_activity = time.time() elif key in (KEY_RIGHT,): active_col = min(COLS - 1, active_col + 1) last_activity = time.time() elif key in ("1", "2", "3", "4", "5", "6", "7"): col = int(key) - 1 r = get_first_empty_row(board, col) if r != -1: active_col = col current_moves += str(col) board[col][r] = current_player if not check_game_end(): if menu_mode < 2: game_state = State.AI_TURN else: current_player = 2 if current_player == 1 else 1 last_activity = time.time() elif key in CONFIRM_KEYS: r = get_first_empty_row(board, active_col) if r != -1: current_moves += str(active_col) board[active_col][r] = current_player if not check_game_end(): if menu_mode < 2: game_state = State.AI_TURN else: current_player = 2 if current_player == 1 else 1 last_activity = time.time() live.update(render_board(board, active_col, current_player)) # --- AI_TURN --- elif game_state == State.AI_TURN: ai_p = 2 if menu_mode == 0 else 1 live.update(render_board(board, -1, ai_p, thinking_col=active_col, thinking_bright=True)) best_col = perform_ai_move(board, ai_p, LOOK_AHEAD) r = get_first_empty_row(board, best_col) if r != -1: current_moves += str(best_col) board[best_col][r] = ai_p active_col = best_col if not check_game_end(): game_state = State.PLAYING current_player = 2 if ai_p == 1 else 1 last_activity = time.time() live.update(render_board(board, active_col, current_player, win_positions if winner_player else None)) # --- DEMO --- elif game_state == State.DEMO: ply = demo_ply[current_player - 1] best_col = perform_ai_move(board, current_player, LOOK_AHEAD, is_demo=True, demo_ply=ply) r = get_first_empty_row(board, best_col) if r != -1: board[best_col][r] = current_player if not check_game_end(): current_player = 2 if current_player == 1 else 1 live.update(render_board(board, -1, 0)) time.sleep(0.4) # --- FINISHED --- elif game_state in (State.FINISHED_WIN, State.FINISHED_DRAW): now = time.time() if now - last_flash > 0.4: last_flash = now flash_toggle = not flash_toggle if game_state == State.FINISHED_WIN: style = player_style(winner_player) status = Text.from_markup( f"\n [{style}]{player_name(winner_player)} wins![/{style}] [dim]Press any key for menu[/dim]\n" ) tbl = render_board(board, -1, 0, win_positions, flash_off=flash_toggle) else: status = Text.from_markup( "\n [bold]Draw![/bold] [dim]Press any key for menu[/dim]\n" ) tbl = render_board(board, -1, 0, is_draw_flash=True, flash_off=flash_toggle) live.update(Group(tbl, status)) # Auto-restart to demo after pause if time.time() - demo_reset_timer > DEMO_RESET_PAUSE: reset() demo_ply = randomize_demo_plies() game_state = State.DEMO current_player = 1 last_activity = time.time() time.sleep(0.05) except KeyboardInterrupt: pass finally: _input_stop.set() console.clear() console.print("[bold]Thanks for playing![/bold]") if __name__ == "__main__": main()