{add] Python TEXT game for Jef!

This commit is contained in:
2026-03-21 16:27:12 +01:00
parent 3257d40722
commit d5345c6cee
4 changed files with 612 additions and 0 deletions
+11
View File
@@ -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
+5
View File
@@ -5,3 +5,8 @@
.vscode/ipch
.vscode/settings.json
CLAUDE.md
.venv/
__pycache__/
*.pyc
.games.txt
uv.lock
+583
View File
@@ -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()
+13
View File
@@ -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"