"""Connect Four terminal game with AI, 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 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 0 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 1: instant win / block 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] = 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()