From d5345c6cee5badaa2518ddfd8e4358fb393da06f Mon Sep 17 00:00:00 2001 From: Seppe De Loore Date: Sat, 21 Mar 2026 16:27:12 +0100 Subject: [PATCH] {add] Python TEXT game for Jef! --- .env | 11 + .gitignore | 5 + connect_four.py | 583 ++++++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 13 ++ 4 files changed, 612 insertions(+) create mode 100644 .env create mode 100644 connect_four.py create mode 100644 pyproject.toml diff --git a/.env b/.env new file mode 100644 index 0000000..52073ed --- /dev/null +++ b/.env @@ -0,0 +1,11 @@ +# AI Settings +LOOK_AHEAD=8 +BLUNDER_ENABLED=false +BLUNDER_CHANCE=20 + +# Demo Settings +DEMO_RESET_PAUSE=5 +IDLE_TIMEOUT=60 + +# Game Log +MAX_GAME_LOG=100 diff --git a/.gitignore b/.gitignore index 4aefb69..608686a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,8 @@ .vscode/ipch .vscode/settings.json CLAUDE.md +.venv/ +__pycache__/ +*.pyc +.games.txt +uv.lock diff --git a/connect_four.py b/connect_four.py new file mode 100644 index 0000000..1cf6f26 --- /dev/null +++ b/connect_four.py @@ -0,0 +1,583 @@ +"""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() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5f10da7 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "connect-four-terminal" +version = "1.0.0" +description = "Connect Four terminal game with AI" +requires-python = ">=3.10" +dependencies = [ + "rich>=13.0", + "python-dotenv>=1.0", + "readchar>=4.0", +] + +[project.scripts] +connect-four = "connect_four:main"