{add] Python TEXT game for Jef!
This commit is contained in:
@@ -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,3 +5,8 @@
|
||||
.vscode/ipch
|
||||
.vscode/settings.json
|
||||
CLAUDE.md
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.games.txt
|
||||
uv.lock
|
||||
|
||||
+583
@@ -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()
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user