Files
seppedl b27032762e [fix] Add heuristic evaluation, fork detection, and Phase 1 win/block split to AI.
Minimax leaf nodes now return a positional score instead of 0, using
playable-threat detection (±100), non-playable threats (±40), fork
bonus (±200), two-in-a-row (±5), and center control (±3). Phase 1
is split into two passes so the AI never blocks when it can win.
Game sequence is now auto-logged to the browser console on game end.
Applied to all three implementations (C++, JS, Python).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 16:59:55 +01:00

658 lines
22 KiB
Python

"""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()