first commit
- .gitignore
- LICENSE
- README.md
- core/__init__.py
- core/input.py
- core/menu.py
- core/storage.py
- core/terminal.py
- core/utils.py
- games/__init__.py
- games/checkers.py
- games/connect_four.py
- games/fifteen.py
- games/game_2048.py
- games/gomoku.py
- games/mancala.py
- games/minesweeper.py
- games/naval_battle.py
- games/reversi.py
- games/tic_tac_toe.py
- main.py
- play.bat
- play.sh
- tests/test_2048.py
- tests/test_checkers.py
- tests/test_connect_four.py
- tests/test_fifteen.py
- tests/test_gomoku.py
- tests/test_input.py
- tests/test_mancala.py
- tests/test_menu.py
- tests/test_minesweeper.py
- tests/test_naval_battle.py
- tests/test_registry.py
- tests/test_reversi.py
- tests/test_storage.py
- tests/test_tic_tac_toe.py
@@ -0,0 +1,33 @@+# Byte-compiled / cache +__pycache__/ +*.py[cod] +*$py.class +*.pyo + +# Virtual environments +.venv/ +venv/ +env/ + +# Local game statistics (player data) +stats.json +stats.tmp + +# Test / coverage +.pytest_cache/ +.coverage +htmlcov/ +.mypy_cache/ +.ruff_cache/ + +# OS +.DS_Store +Thumbs.db +Desktop.ini + +# Editors / IDEs +.idea/ +.vscode/ +*.swp +*.swo +*~
A
LICENSE
@@ -0,0 +1,21 @@+MIT License + +Copyright (c) 2026 Pablo Murad + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE.
@@ -0,0 +1,194 @@+# Pablo's Terminal Games + +A collection of ten small board, strategy and puzzle games designed to +be played entirely inside the terminal. No browser, no GUI, no +accounts, no internet — just games. + +``` ++================================+ +| | +| PABLO'S TERMINAL GAMES | +| | +| VERSION 0.0.1 | +| | ++================================+ + + > 1. Tic-Tac-Toe + 2. Connect Four + 3. Checkers + 4. Reversi + 5. Naval Battle + 6. Gomoku + 7. Mancala + 8. Minesweeper + 9. 2048 + 0. Fifteen Puzzle + + Arrows/W-S move - Enter select - H help - S stats - A about - Q quit +``` + + + +## Games included + + +| # | Game | Modes | +| --- | -------------- | ----------------------- | +| 1 | Tic-Tac-Toe | vs Computer, vs Player | +| 2 | Connect Four | vs Computer, vs Player | +| 3 | Checkers | vs Computer, vs Player | +| 4 | Reversi | vs Computer, vs Player | +| 5 | Naval Battle | vs Computer | +| 6 | Gomoku | vs Computer, vs Player | +| 7 | Mancala | vs Computer, vs Player | +| 8 | Minesweeper | single player, 3 levels | +| 9 | 2048 | single player | +| 0 | Fifteen Puzzle | single player | + + +Every game has its own help screen (press `H` in-game) explaining the +objective, controls and rules. + +## Features + +- Retro terminal interface with arrow-key menus, ANSI colors and +Unicode box drawing (with automatic ASCII fallback). +- Cross-platform keyboard input: works on Windows (msvcrt) and +Linux/Unix (termios) with no configuration. +- Correct game rules kept separate from rendering, so they are fully +unit-tested (236 tests). +- Local JSON statistics: games played, won, lost and drawn, plus +records (2048 high score and best tile, Minesweeper best times, +Fifteen Puzzle best moves/time). +- Graceful behavior: invalid input is explained and re-asked, the +terminal is always restored on exit, and a corrupted statistics +file never crashes the program. +- Completely offline. No telemetry, no analytics, no network +requests, no accounts. Everything stays on your computer. + + + +## Requirements + +- Python 3.8 or newer (standard library only — nothing to install). +- A terminal of at least 80x24 characters. + + + +## Installation + +Clone or copy this directory anywhere. There is no build step and no +dependency to install. + +## Running + + + +### Windows + +``` +python main.py +``` + +or double-click / run: + +``` +play.bat +``` + +Works in Windows PowerShell, cmd and Windows Terminal. + +### Linux + +``` +python3 main.py +``` + +or: + +``` +./play.sh +``` + +Works in any standard terminal emulator (Debian/Ubuntu included). + +## Controls + +- **Menus**: Arrow keys or `W`/`S` to move, `Enter` to select, +number keys as shortcuts, `Q` or `ESC` to go back. +- **Board games** (Tic-Tac-Toe, Connect Four, Checkers, Reversi, +Naval Battle, Gomoku, Mancala): type coordinates or numbers +(e.g. `b2`, `d3`, `b6-a5`, `E5`, `1-6`) and press Enter. +- **Real-time games** (Minesweeper, 2048, Fifteen Puzzle): arrow +keys and WASD. Minesweeper: `Enter`/`Space` reveals, `F` flags. +- **Everywhere**: `H` for help, `Q`/`ESC` to return to the menu. + +Note: game 10 (Fifteen Puzzle) is selected with the `0` key. + +## Statistics + +Statistics are stored in a local `stats.json` file next to `main.py` +and survive between executions. View them from the main menu with +`S`. No data ever leaves your computer; delete the file to reset +everything. + +## Project structure + +``` +terminal-games/ +| +├── main.py entry point, main menu, help/stats/about +├── play.bat Windows launcher +├── play.sh Linux launcher +├── GAME_SPEC.md internal contract for game modules +│ +├── core/ +│ ├── __init__.py app metadata (version lives here, once) +│ ├── terminal.py screen, colors, boxes, centering, prompts +│ ├── input.py cross-platform single-key and line input +│ ├── menu.py reusable arrow-key menu +│ ├── storage.py local JSON statistics +│ └── utils.py small shared helpers +│ +├── games/ one module per game, all following +│ ├── tic_tac_toe.py GAME_SPEC.md (pure logic + UI layer) +│ ├── connect_four.py +│ ├── checkers.py +│ ├── reversi.py +│ ├── naval_battle.py +│ ├── gomoku.py +│ ├── mancala.py +│ ├── minesweeper.py +│ ├── game_2048.py +│ └── fifteen.py +│ +└── tests/ one unittest file per game (headless) +``` + + + +## Development + +Game rules live in pure logic classes with no printing or input, +which is what the tests drive. The `Game` class in each module is the +terminal UI on top. To add a new game, follow `GAME_SPEC.md` and +register the module in `main.py`. + +## Running tests + +``` +python -m unittest discover -s tests +``` + +All 236 tests should pass in well under a second. + +## Author + +Pablo Murad +[pablomurad@pm.me](mailto:pablomurad@pm.me) + +Made for people who still enjoy the command line. + +## License + +MIT — see [LICENSE](LICENSE). Copyright (c) 2026 Pablo Murad.
@@ -0,0 +1,11 @@+"""PABLO'S TERMINAL GAMES - core package. + +Single source of truth for application metadata. +""" + +APP_NAME = "PABLO'S TERMINAL GAMES" +__version__ = "0.0.1" +AUTHOR = "Pablo Murad" +EMAIL = "pablomurad@pm.me" +YEAR = "2026" +TAGLINE = "Made for people who still enjoy the command line."
@@ -0,0 +1,78 @@+"""Cross-platform single-key input for Pablo's Terminal Games. + +The rest of the application never needs to know which operating +system it runs on. Use read_key() for real-time games and menus, +and read_line() for coordinate-style text input. +""" +from __future__ import annotations + +import sys + + +class Key: + """Symbolic names for special keys.""" + + UP = "UP" + DOWN = "DOWN" + LEFT = "LEFT" + RIGHT = "RIGHT" + ENTER = "ENTER" + ESCAPE = "ESCAPE" + BACKSPACE = "BACKSPACE" + + +if sys.platform == "win32": + import msvcrt + + _WIN_ARROWS = {"H": Key.UP, "P": Key.DOWN, "K": Key.LEFT, "M": Key.RIGHT} + + def read_key() -> str: + """Read one key press. Returns a Key constant or a single char.""" + ch = msvcrt.getwch() + if ch in ("\x00", "\xe0"): # special key prefix + code = msvcrt.getwch() + return _WIN_ARROWS.get(code, "") + if ch == "\r": + return Key.ENTER + if ch == "\x1b": + return Key.ESCAPE + if ch == "\x08": + return Key.BACKSPACE + return ch + +else: + import select + import termios + import tty + + _UNIX_ARROWS = {"[A": Key.UP, "[B": Key.DOWN, "[C": Key.RIGHT, "[D": Key.LEFT} + + def read_key() -> str: + """Read one key press. Returns a Key constant or a single char.""" + fd = sys.stdin.fileno() + old = termios.tcgetattr(fd) + try: + tty.setraw(fd) + ch = sys.stdin.read(1) + if ch == "\x1b": + # Distinguish a lone ESC from an escape sequence. + if select.select([sys.stdin], [], [], 0.05)[0]: + seq = sys.stdin.read(2) + return _UNIX_ARROWS.get(seq, Key.ESCAPE) + return Key.ESCAPE + if ch in ("\r", "\n"): + return Key.ENTER + if ch == "\x7f": + return Key.BACKSPACE + return ch + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old) + + +def read_line(prompt: str = "") -> str | None: + """Read a line of text. Returns None on EOF/Ctrl-C.""" + try: + return input(prompt) + except (EOFError, KeyboardInterrupt): + print() + return None
@@ -0,0 +1,103 @@+"""Lightweight persistent local statistics (JSON only). + +Fully offline: no network, no accounts, no telemetry. Handles a +missing or corrupted statistics file gracefully by starting fresh. +""" +from __future__ import annotations + +import json +import os +from pathlib import Path + +DEFAULT_PATH = Path(__file__).resolve().parent.parent / "stats.json" + +OUTCOMES = ("win", "loss", "draw") + +_STATS_FIELDS = ("played", "won", "lost", "drawn") + + +def _empty_stats() -> dict: + return {field: 0 for field in _STATS_FIELDS} + + +def _default_data() -> dict: + return {"games": {}, "records": {}} + + +class Store: + """JSON-backed statistics store.""" + + def __init__(self, path=DEFAULT_PATH): + self.path = Path(path) + self.data = _default_data() + self._load() + + def _load(self) -> None: + try: + with open(self.path, "r", encoding="utf-8") as fh: + loaded = json.load(fh) + if not isinstance(loaded, dict): + return + except (OSError, ValueError): + return # missing or corrupted file: start fresh + games = loaded.get("games") + if isinstance(games, dict): + for key, value in games.items(): + if isinstance(value, dict): + merged = self.data["games"].setdefault(key, _empty_stats()) + for field in _STATS_FIELDS: + # type() is int, not isinstance(): JSON true/false + # decode to bool, which is a subclass of int. + if type(value.get(field)) is int: + merged[field] = value[field] + records = loaded.get("records") + if isinstance(records, dict): + self.data["records"] = records + + def save(self) -> None: + """Write statistics atomically (temp file + replace).""" + tmp = self.path.with_suffix(".tmp") + try: + with open(tmp, "w", encoding="utf-8") as fh: + json.dump(self.data, fh, indent=2) + os.replace(tmp, self.path) + except OSError: + pass # statistics are nice to have, never fatal + + def record_result(self, game_key: str, outcome: str) -> None: + """Record one finished game. outcome: 'win', 'loss' or 'draw'.""" + if outcome not in OUTCOMES: + return + entry = self.data["games"].setdefault(game_key, _empty_stats()) + entry["played"] += 1 + entry[{"win": "won", "loss": "lost", "draw": "drawn"}[outcome]] += 1 + self.save() + + def game_stats(self, game_key: str) -> dict: + return dict(self.data["games"].get(game_key, {})) + + def get_record(self, key: str, default=None): + return self.data["records"].get(key, default) + + def set_record(self, key: str, value) -> None: + self.data["records"][key] = value + self.save() + + def improve_record(self, key: str, value, lower_is_better: bool = False) -> bool: + """Store value if it beats the current record. Returns True if it did.""" + current = self.data["records"].get(key) + if current is None or (value < current if lower_is_better else value > current): + self.set_record(key, value) + return True + return False + + +_store: Store | None = None + + +def get_store() -> Store: + """Return the process-wide statistics store.""" + global _store + if _store is None: + _store = Store() + return _store
@@ -0,0 +1,215 @@+"""Terminal rendering helpers for Pablo's Terminal Games. + +Screen control, ANSI colors, text centering, boxes and small UI +helpers. Everything degrades gracefully when ANSI colors or Unicode +are not available. Game logic must never depend on this module. +""" +from __future__ import annotations + +import os +import re +import shutil +import sys + +MIN_COLS = 80 +MIN_ROWS = 24 + +_ANSI_RE = re.compile(r"\033\[[0-9;?]*[a-zA-Z]") + + +def _enable_windows_ansi() -> bool: + """Enable virtual terminal processing on Windows 10+ consoles.""" + if sys.platform != "win32": + return False + try: + import ctypes + + kernel32 = ctypes.windll.kernel32 + handle = kernel32.GetStdHandle(-11) # STD_OUTPUT_HANDLE + mode = ctypes.c_ulong() + if not kernel32.GetConsoleMode(handle, ctypes.byref(mode)): + return False + ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004 + return bool( + kernel32.SetConsoleMode(handle, mode.value | ENABLE_VIRTUAL_TERMINAL_PROCESSING) + ) + except Exception: + return False + + +def _detect_ansi() -> bool: + if os.environ.get("NO_COLOR"): + return False + if sys.platform == "win32": + return _enable_windows_ansi() + return hasattr(sys.stdout, "isatty") and sys.stdout.isatty() + + +def _detect_unicode() -> bool: + encoding = (getattr(sys.stdout, "encoding", None) or "").lower() + return "utf" in encoding + + +ANSI = _detect_ansi() +UNICODE = _detect_unicode() + + +class Color: + """Semantic terminal colors. Use sparingly.""" + + RESET = "\033[0m" + BOLD = "\033[1m" + DIM = "\033[2m" + RED = "\033[31m" + GREEN = "\033[32m" + YELLOW = "\033[33m" + CYAN = "\033[36m" + WHITE = "\033[37m" + GRAY = "\033[90m" + + +STATUS_COLORS = { + "info": Color.CYAN, + "ok": Color.GREEN, + "warn": Color.YELLOW, + "error": Color.RED, + "dim": Color.GRAY, +} + + +def style(text: object, *codes: str) -> str: + """Wrap text in ANSI codes, or return it plain when unsupported.""" + text = str(text) + if not ANSI or not codes: + return text + return "".join(codes) + text + Color.RESET + + +def visible_len(text: str) -> int: + """Length of text ignoring ANSI escape sequences.""" + return len(_ANSI_RE.sub("", text)) + + +# ---------------------------------------------------------------- screen + +def clear() -> None: + if ANSI: + sys.stdout.write("\033[2J\033[H") + else: + sys.stdout.write("\n" * 3) + sys.stdout.flush() + + +def move_to(row: int, col: int) -> None: + if ANSI: + sys.stdout.write(f"\033[{row};{col}H") + + +def hide_cursor() -> None: + if ANSI: + sys.stdout.write("\033[?25l") + sys.stdout.flush() + + +def show_cursor() -> None: + if ANSI: + sys.stdout.write("\033[?25h") + sys.stdout.flush() + + +def reset_style() -> None: + if ANSI: + sys.stdout.write(Color.RESET) + + +def cleanup() -> None: + """Restore the terminal to a sane state before exiting.""" + reset_style() + show_cursor() + sys.stdout.flush() + + +def size() -> tuple[int, int]: + s = shutil.get_terminal_size((MIN_COLS, MIN_ROWS)) + return s.columns, s.lines + + +def too_small() -> bool: + cols, rows = size() + return cols < MIN_COLS or rows < MIN_ROWS + + +# ---------------------------------------------------------------- text + +def center(text: str, width: int | None = None) -> str: + """Center text (ANSI-aware) within width, default terminal width.""" + if width is None: + width = size()[0] + pad = max(0, (width - visible_len(text)) // 2) + return " " * pad + text + + +def print_centered(lines, width: int | None = None) -> None: + for line in lines: + print(center(line, width)) + + +def status(message: str, kind: str = "info") -> None: + """Print a colored status line.""" + print(style(message, STATUS_COLORS.get(kind, ""))) + + +# ---------------------------------------------------------------- boxes + +if UNICODE: + BOX = { + "tl": "┌", "tr": "┐", "bl": "└", "br": "┘", + "h": "─", "v": "│", + "lt": "├", "rt": "┤", "tt": "┬", "bt": "┴", "x": "┼", + } + BOX_DOUBLE = { + "tl": "╔", "tr": "╗", "bl": "╚", "br": "╝", "h": "═", "v": "║", + } +else: + BOX = { + "tl": "+", "tr": "+", "bl": "+", "br": "+", + "h": "-", "v": "|", + "lt": "+", "rt": "+", "tt": "+", "bt": "+", "x": "+", + } + BOX_DOUBLE = { + "tl": "+", "tr": "+", "bl": "+", "br": "+", "h": "=", "v": "|", + } + + +def box(lines, pad: int = 1, double: bool = False) -> list[str]: + """Frame a list of text lines in a box. Returns list of strings.""" + chars = BOX_DOUBLE if double else BOX + width = max((visible_len(l) for l in lines), default=0) + pad * 2 + out = [chars["tl"] + chars["h"] * width + chars["tr"]] + for line in lines: + space = width - pad - visible_len(line) + out.append(chars["v"] + " " * pad + line + " " * space + chars["v"]) + out.append(chars["bl"] + chars["h"] * width + chars["br"]) + return out + + +# ---------------------------------------------------------------- prompts + +def confirm(prompt: str, default: bool = False) -> bool: + """Ask a [Y/N] question. Returns the boolean answer.""" + suffix = "[Y/n]" if default else "[y/N]" + try: + answer = input(f"{prompt} {suffix} ").strip().lower() + except (EOFError, KeyboardInterrupt): + print() + return False + if not answer: + return default + return answer.startswith("y") + + +def press_enter(message: str = "Press Enter to continue...") -> None: + try: + input(style(message, Color.GRAY)) + except (EOFError, KeyboardInterrupt): + print()
@@ -0,0 +1,20 @@+"""Small shared utilities.""" +from __future__ import annotations + + +def format_time(seconds) -> str: + """Format elapsed seconds as MM:SS (or H:MM:SS for long runs).""" + seconds = int(seconds or 0) + hours, rem = divmod(seconds, 3600) + minutes, secs = divmod(rem, 60) + if hours: + return f"{hours}:{minutes:02d}:{secs:02d}" + return f"{minutes:02d}:{secs:02d}" + + +def safe_int(text, default=None): + """Parse an int without raising.""" + try: + return int(str(text).strip()) + except (TypeError, ValueError): + return default
@@ -0,0 +1,5 @@+"""Game modules for Pablo's Terminal Games. + +Each module exposes NAME, KEY, DESCRIPTION and a Game class with +run(). See GAME_SPEC.md for the full contract. +"""
@@ -0,0 +1,545 @@+"""Checkers - standard English/American 8x8 draughts. + +Pure game logic lives in ``Move``, ``CheckersState``, ``parse_move`` +and ``choose_computer_chain`` (no printing, no input) so unit tests +can drive them headless. ``Game`` is the interactive UI layer on top. + +Board orientation: black ("b") starts at the top (rows 1-3), white +("w") at the bottom (rows 6-8). White moves first and moves "up" +(towards row 1). Kings are the uppercase letters "W"/"B". Only the +dark squares are playable; row 1 is at the top of the screen. +""" +from __future__ import annotations + +import random +import re +import time + +from core import terminal as term +from core.input import read_line +from core.menu import choose +from core.storage import get_store + +NAME = "Checkers" +KEY = "checkers" +DESCRIPTION = "Classic 8x8 draughts: jump all of the enemy pieces." + +SIZE = 8 +WHITE = "w" +BLACK = "b" +EMPTY = " " +COLORS = (WHITE, BLACK) +COLUMNS = "abcdefgh" + +_MOVE_RE = re.compile( + r"^\s*([a-hA-H])([1-8])\s*[-,;:]?\s*([a-hA-H])([1-8])\s*$" +) + + +def opponent(color): + """Return the other color.""" + return BLACK if color == WHITE else WHITE + + +def color_of(piece): + """Color ('w' or 'b') of a piece letter, or None for empty.""" + if piece in (EMPTY, None): + return None + return piece.lower() + + +def is_king(piece): + """True when the piece letter is a king (uppercase).""" + return piece.isupper() + + +def in_bounds(row, col): + return 0 <= row < SIZE and 0 <= col < SIZE + + +def is_playable(row, col): + """Only dark squares are used; on those (row + col) is even.""" + return in_bounds(row, col) and (row + col) % 2 == 0 + + +def square_to_coords(text): + """Parse one square like 'b6' (case-insensitive) into (row, col). + + Returns None when the text is not a valid on-board square. + """ + if not text: + return None + text = text.strip().lower() + if len(text) != 2: + return None + col_char, row_char = text[0], text[1] + if col_char not in COLUMNS or row_char not in "12345678": + return None + return int(row_char) - 1, COLUMNS.index(col_char) + + +def coords_to_square(row, col): + """Inverse of square_to_coords: (5, 1) -> 'b6'.""" + return f"{COLUMNS[col]}{row + 1}" + + +def parse_move(text): + """Parse 'b6-a5', 'b6 a5' or 'b6a5' into ((row, col), (row, col)). + + Returns None when the text is not a well-formed move on the board. + """ + if not text: + return None + match = _MOVE_RE.match(text) + if not match: + return None + src = square_to_coords(match.group(1) + match.group(2)) + dst = square_to_coords(match.group(3) + match.group(4)) + if src is None or dst is None: + return None + return src, dst + + +class Move: + """One single step: a simple diagonal move or one jump.""" + + __slots__ = ("src", "dst") + + def __init__(self, src, dst): + self.src = src + self.dst = dst + + @property + def is_capture(self): + return abs(self.src[0] - self.dst[0]) == 2 + + @property + def captured(self): + """Square of the jumped piece (only for captures).""" + return ((self.src[0] + self.dst[0]) // 2, + (self.src[1] + self.dst[1]) // 2) + + def notation(self): + sep = "x" if self.is_capture else "-" + return (coords_to_square(*self.src) + sep + + coords_to_square(*self.dst)) + + def __eq__(self, other): + return (isinstance(other, Move) + and self.src == other.src and self.dst == other.dst) + + def __hash__(self): + return hash((self.src, self.dst)) + + def __repr__(self): + return f"Move({self.notation()})" + + +class CheckersState: + """Pure checkers game state. No printing, no input. + + Moves are applied one single step at a time. After a jump that + can be continued, ``must_continue`` holds the square of the piece + that has to keep jumping; only captures from that square are then + legal. Reaching the last row promotes immediately and ends the + turn (English rules), even in the middle of a jump sequence. + """ + + def __init__(self, setup=True): + self.board = [[EMPTY] * SIZE for _ in range(SIZE)] + if setup: + for row in range(SIZE): + for col in range(SIZE): + if not is_playable(row, col): + continue + if row < 3: + self.board[row][col] = BLACK + elif row > 4: + self.board[row][col] = WHITE + self.turn = WHITE + self.must_continue = None # (row, col) of a piece mid-chain + + # ---------------------------------------------------------- basics + + def clone(self): + """A deep copy that can be moved on without touching this one.""" + other = CheckersState(setup=False) + other.board = [row[:] for row in self.board] + other.turn = self.turn + other.must_continue = self.must_continue + return other + + def piece_at(self, row, col): + return self.board[row][col] + + def set_piece(self, row, col, piece): + """Place a piece (or EMPTY) on a square. Test/setup helper.""" + self.board[row][col] = piece + + def pieces(self, color): + """Dict of {(row, col): piece} for one color.""" + return {(r, c): self.board[r][c] + for r in range(SIZE) for c in range(SIZE) + if color_of(self.board[r][c]) == color} + + def last_row(self, color): + return 0 if color == WHITE else SIZE - 1 + + # ---------------------------------------------------- move lookup + + def _directions(self, piece): + """Forward dr values: men move one way, kings move both.""" + if is_king(piece): + return (-1, 1) + return (-1,) if piece == WHITE else (1,) + + def _steps_from(self, row, col): + """Simple (non-capture) diagonal moves of the piece here.""" + piece = self.board[row][col] + moves = [] + for dr in self._directions(piece): + for dc in (-1, 1): + nr, nc = row + dr, col + dc + if in_bounds(nr, nc) and self.board[nr][nc] == EMPTY: + moves.append(Move((row, col), (nr, nc))) + return moves + + def _jumps_from(self, row, col): + """Single jumps of the piece here (ignores global legality).""" + piece = self.board[row][col] + enemy = opponent(color_of(piece)) + moves = [] + for dr in self._directions(piece): + for dc in (-1, 1): + mr, mc = row + dr, col + dc # jumped square + lr, lc = row + 2 * dr, col + 2 * dc # landing square + if (in_bounds(lr, lc) + and self.board[lr][lc] == EMPTY + and color_of(self.board[mr][mc]) == enemy): + moves.append(Move((row, col), (lr, lc))) + return moves + + def legal_moves(self): + """All single-step moves the player to move may play now. + + Captures are mandatory: when any jump exists, only jumps are + legal. While ``must_continue`` is set, only jumps with that + same piece are legal. + """ + if self.must_continue is not None: + return self._jumps_from(*self.must_continue) + jumps = [] + steps = [] + for row, col in self.pieces(self.turn): + jumps.extend(self._jumps_from(row, col)) + steps.extend(self._steps_from(row, col)) + return jumps if jumps else steps + + def capture_chains(self, row, col): + """All maximal jump sequences for the piece at (row, col). + + Returns a list of chains; each chain is a list of ``Move``. + An empty list means the piece cannot jump. + """ + if color_of(self.board[row][col]) != self.turn: + return [] + chains = [] + + def walk(state, pos, chain): + jumps = state._jumps_from(*pos) + if not jumps: + if chain: + chains.append(chain) + return + for jump in jumps: + nxt = state.clone() + nxt.apply(jump) + if nxt.turn != state.turn: + chains.append(chain + [jump]) # promotion ends it + else: + walk(nxt, jump.dst, chain + [jump]) + + walk(self.clone(), (row, col), []) + return chains + + # -------------------------------------------------------- applying + + def apply(self, move): + """Play one single-step move. Raises ValueError if illegal.""" + if move not in self.legal_moves(): + raise ValueError(f"Illegal move: {move.notation()}") + piece = self.board[move.src[0]][move.src[1]] + self.board[move.src[0]][move.src[1]] = EMPTY + self.board[move.dst[0]][move.dst[1]] = piece + if move.is_capture: + cr, cc = move.captured + self.board[cr][cc] = EMPTY + promoted = False + if (not is_king(piece) + and move.dst[0] == self.last_row(self.turn)): + self.board[move.dst[0]][move.dst[1]] = piece.upper() + promoted = True + if move.is_capture and not promoted: + if self._jumps_from(*move.dst): + self.must_continue = move.dst + return + self.must_continue = None + self.turn = opponent(self.turn) + + # --------------------------------------------------------- outcome + + def winner(self): + """'w' or 'b' when the game is decided, else None. + + A player wins when the opponent has no pieces left or no + legal move on their turn (while not mid-chain). + """ + if self.must_continue is not None: + return None + if not self.pieces(self.turn): + return opponent(self.turn) + if not self.legal_moves(): + return opponent(self.turn) + return None + + +# ------------------------------------------------------------- computer + + +def _hands_opponent_a_capture(state, move): + """True when playing ``move`` gives the opponent a direct jump.""" + nxt = state.clone() + nxt.apply(move) + if nxt.turn == state.turn: + return False # move continued a chain; opponent not to move + return any(m.is_capture for m in nxt.legal_moves()) + + +def _advance_score(move, color): + """How far towards the promotion row the destination lies.""" + if color == WHITE: + return (SIZE - 1) - move.dst[0] + return move.dst[0] + + +def choose_computer_chain(state, color, rng=None): + """Pick the computer's play as a list of single-step Moves. + + Priority: 1. captures (most jumps win), 2. moves that do not + hand the opponent a capture, 3. promotions, 4. the most advanced + destination. Ties are broken at random. + """ + rng = rng or random + chains = [] + for row, col in state.pieces(color): + chains.extend(state.capture_chains(row, col)) + if chains: + longest = max(len(chain) for chain in chains) + return rng.choice([c for c in chains if len(c) == longest]) + + moves = [m for m in state.legal_moves() if not m.is_capture] + if not moves: + return [] + safe = [m for m in moves if not _hands_opponent_a_capture(state, m)] + pool = safe or moves + promoting = [m for m in pool + if not is_king(state.piece_at(*m.src)) + and m.dst[0] == state.last_row(color)] + if promoting: + return [rng.choice(promoting)] + best = max(_advance_score(m, color) for m in pool) + return [rng.choice([m for m in pool + if _advance_score(m, color) == best])] + + +# ------------------------------------------------------------------- UI + + +class Game: + """Interactive UI layer: menus, rendering, prompts, statistics.""" + + def run(self): + try: + term.hide_cursor() + while True: + mode = self._choose_mode() + if mode is None: + return + self._play_match(mode) + if not term.confirm("Play again?"): + return + finally: + term.show_cursor() + + # ------------------------------------------------------------ menus + + def _choose_mode(self): + title = term.style(NAME, term.Color.CYAN, term.Color.BOLD) + choice = choose( + [title, "", DESCRIPTION, "", "You play White and move first."], + ["Player vs Computer", "Player vs Player"], + hotkeys=["1", "2"], + extra_keys="h", + hint="H help - Q/ESC return to menu", + ) + if choice == "h": + self._show_help() + return self._choose_mode() + return choice # 0 (vs computer), 1 (vs player) or None (quit) + + def _show_help(self): + term.clear() + print() + lines = [ + term.style(f"HOW TO PLAY - {NAME}", term.Color.CYAN, term.Color.BOLD), + "", + term.style("Objective", term.Color.CYAN), + "Capture all enemy pieces, or leave your opponent with no", + "legal move. White moves first.", + "", + term.style("Controls", term.Color.CYAN), + "Type a move as FROM-TO, for example b6-a5 or b6 a5, and", + "press Enter. Columns are a-h, rows are 1-8.", + " H show this help screen", + " Q return to the main menu", + "", + term.style("Rules", term.Color.CYAN), + "Men (w/b) move one square diagonally forward; kings", + "(W/B) also move backwards. Captures are made by jumping", + "over an enemy piece and are MANDATORY: when a jump is", + "available, only jumps are legal. A jumped piece that can", + "jump again must keep jumping with the same piece. A man", + "reaching the last row is promoted to a king.", + ] + for line in term.box(lines, pad=2): + print(term.center(line)) + print() + term.press_enter() + + # ------------------------------------------------------------ match + + def _play_match(self, mode): + state = CheckersState() + message = "" + while True: + winner = state.winner() + if winner is not None: + self._render(state, "") + self._announce_result(winner, mode) + return + if mode == 0 and state.turn == BLACK: + self._computer_turn(state) + message = "" + else: + action = self._human_turn(state, mode, message) + message = "" + if action == "quit": + if term.confirm("Return to the main menu?"): + return + elif action == "help": + self._show_help() + + def _computer_turn(self, state): + chain = choose_computer_chain(state, BLACK) + if not chain: + return + self._render(state, "The computer is thinking...") + time.sleep(0.6) + for move in chain: + state.apply(move) + self._render(state, f"Computer plays {move.notation()}.") + time.sleep(0.6) + + def _human_turn(self, state, mode, message): + """Ask for a move. Applies it, or returns 'help' / 'quit'.""" + label = self._turn_label(state, mode) + while True: + self._render(state, message) + print(term.center(label)) + answer = read_line(term.center("Move (e.g. b6-a5, h help, q quit): ")) + if answer is None: + return "quit" + answer = answer.strip().lower() + if answer == "q": + return "quit" + if answer == "h": + return "help" + parsed = parse_move(answer) + if parsed is None: + message = "Invalid move. Use something like b6-a5." + continue + move = Move(*parsed) + if state.must_continue is not None and move.src != state.must_continue: + square = coords_to_square(*state.must_continue) + message = f"You must keep jumping with the piece on {square}." + continue + if move not in state.legal_moves(): + if any(m.is_capture for m in state.legal_moves()): + message = "A capture is mandatory. You must jump." + else: + message = "Illegal move. Try again." + continue + state.apply(move) + return "moved" + + def _turn_label(self, state, mode): + side = "White" if state.turn == WHITE else "Black" + if mode == 0: + return f"Your move ({side})" + return f"{side} to move" + + # ------------------------------------------------------------ render + + def _render(self, state, message): + term.clear() + print() + term.print_centered([term.style(NAME, term.Color.CYAN, term.Color.BOLD), ""]) + for line in self._board_lines(state): + print(term.center(line)) + print() + if state.must_continue is not None: + square = coords_to_square(*state.must_continue) + print(term.center(term.style( + f"Multi-jump! Continue with {square}.", term.Color.YELLOW))) + if message: + print(term.center(term.style(message, term.Color.GRAY))) + + def _board_lines(self, state): + lines = [" " + " ".join(COLUMNS)] + for row in range(SIZE): + cells = [self._cell_text(state, row, col) for col in range(SIZE)] + lines.append(f" {row + 1} " + " ".join(cells)) + return lines + + def _cell_text(self, state, row, col): + piece = state.piece_at(row, col) + if piece == EMPTY: + if is_playable(row, col): + return " " + return term.style(".", term.Color.GRAY) + if term.UNICODE: + glyphs = {WHITE: "○", "W": "◎", BLACK: "●", "B": "◉"} + text = glyphs[piece] + else: + text = piece + if color_of(piece) == WHITE: + return term.style(text, term.Color.WHITE, term.Color.BOLD) + return term.style(text, term.Color.GRAY) + + # ------------------------------------------------------------ result + + def _announce_result(self, winner, mode): + store = get_store() + print() + side = "White" if winner == WHITE else "Black" + if mode == 0: + if winner == WHITE: + store.record_result(KEY, "win") + term.print_centered([term.style("You win! Congratulations!", term.Color.GREEN)]) + else: + store.record_result(KEY, "loss") + term.print_centered([term.style("The computer wins. Try again!", term.Color.RED)]) + else: + store.record_result(KEY, "win") + term.print_centered([term.style(f"{side} wins!", term.Color.GREEN)]) + print()
@@ -0,0 +1,327 @@+"""Connect Four for Pablo's Terminal Games. + +Drop pieces into columns; the first player to connect four in a row +(horizontally, vertically or diagonally) wins. Supports Player vs +Computer and local Player vs Player. + +The rules live in the pure-logic classes ``Board`` and +``ComputerPlayer`` (no printing, no input); ``Game`` is the UI layer. +""" +from __future__ import annotations + +from core import terminal as term +from core.input import read_line +from core.menu import choose +from core.storage import get_store +from core.utils import safe_int + +NAME = "Connect Four" +KEY = "connect_four" +DESCRIPTION = "Drop pieces and connect four in a row to win." + +EMPTY = 0 +PLAYER_ONE = 1 +PLAYER_TWO = 2 + +COLUMN_COUNT = 7 +ROW_COUNT = 6 +CONNECT = 4 + +# Search order for the AI's positional preference (center first). +CENTER_ORDER = (3, 2, 4, 1, 5, 0, 6) + +_DIRECTIONS = ((0, 1), (1, 0), (1, 1), (1, -1)) + + +class Board: + """Pure Connect Four board state. + + ``grid[row][col]`` holds EMPTY, PLAYER_ONE or PLAYER_TWO. + Row 0 is the TOP row; pieces fall towards the highest row index. + """ + + def __init__(self) -> None: + self.grid = [[EMPTY] * COLUMN_COUNT for _ in range(ROW_COUNT)] + self.moves = 0 + + def is_valid_column(self, column: int) -> bool: + """True when the column index is in range and not full.""" + return 0 <= column < COLUMN_COUNT and self.grid[0][column] == EMPTY + + def valid_columns(self) -> list[int]: + """Column indices that can still accept a piece.""" + return [c for c in range(COLUMN_COUNT) if self.is_valid_column(c)] + + def drop(self, column: int, player: int) -> int | None: + """Drop a piece. Returns the landing row, or None when invalid.""" + if not self.is_valid_column(column): + return None + row = ROW_COUNT - 1 + while self.grid[row][column] != EMPTY: + row -= 1 + self.grid[row][column] = player + self.moves += 1 + return row + + def is_full(self) -> bool: + return self.moves >= COLUMN_COUNT * ROW_COUNT + + def has_won(self, player: int) -> bool: + """True when player has CONNECT pieces in a row anywhere.""" + for row in range(ROW_COUNT): + for col in range(COLUMN_COUNT): + if self.grid[row][col] != player: + continue + for d_row, d_col in _DIRECTIONS: + if self._count_run(row, col, d_row, d_col, player) >= CONNECT: + return True + return False + + def _count_run(self, row: int, col: int, d_row: int, d_col: int, + player: int) -> int: + count = 0 + while (0 <= row < ROW_COUNT and 0 <= col < COLUMN_COUNT + and self.grid[row][col] == player): + count += 1 + row += d_row + col += d_col + return count + + def winner(self) -> int: + """PLAYER_ONE, PLAYER_TWO or EMPTY when nobody has won yet.""" + for player in (PLAYER_ONE, PLAYER_TWO): + if self.has_won(player): + return player + return EMPTY + + def is_draw(self) -> bool: + """Full board with no winner.""" + return self.is_full() and self.winner() == EMPTY + + +class ComputerPlayer: + """Simple Connect Four AI. + + Priority: win immediately, block the opponent's immediate win, + take the most central available column. + """ + + def __init__(self, player: int = PLAYER_TWO) -> None: + self.player = player + self.opponent = PLAYER_ONE if player == PLAYER_TWO else PLAYER_ONE + + def choose_column(self, board: Board) -> int | None: + """Return the column index the computer wants to play.""" + for candidate in (self.player, self.opponent): + move = self._find_winning_move(board, candidate) + if move is not None: + return move + for column in CENTER_ORDER: + if board.is_valid_column(column): + return column + # CENTER_ORDER covers every column; reaching here means the + # board is full, which the game loop never allows. + return None + + @staticmethod + def _find_winning_move(board: Board, player: int) -> int | None: + """Column that completes a four-in-a-row for player, or None.""" + for column in board.valid_columns(): + probe = Board() + probe.grid = [row[:] for row in board.grid] + probe.moves = board.moves + probe.drop(column, player) + if probe.has_won(player): + return column + return None + + +def other_player(player: int) -> int: + return PLAYER_TWO if player == PLAYER_ONE else PLAYER_ONE + + +def player_name(player: int, vs_computer: bool) -> str: + if vs_computer: + return "You" if player == PLAYER_ONE else "Computer" + return f"Player {player}" + + +def piece_symbol(player: int) -> str: + if player == EMPTY: + return " " + if term.UNICODE: + return "\u25cf" if player == PLAYER_ONE else "\u25cb" # ● / ○ + return "X" if player == PLAYER_ONE else "O" + + +def colored_piece(player: int) -> str: + color = term.Color.YELLOW if player == PLAYER_ONE else term.Color.RED + return term.style(piece_symbol(player), color) + + +class Game: + """Interactive Connect Four session (UI layer).""" + + def run(self) -> None: + while True: + vs_computer = self._choose_mode() + if vs_computer is None: + return + self._play_match(vs_computer) + if not term.confirm("Play again?"): + return + + # ------------------------------------------------------------ menus + + def _choose_mode(self) -> bool | None: + """True for Player vs Computer, False for PvP, None to quit.""" + title = [ + term.style("CONNECT FOUR", term.Color.CYAN, term.Color.BOLD), + "", + term.style(DESCRIPTION, term.Color.GRAY), + ] + choice = choose(title, ["Player vs Computer", "Player vs Player"], + hint="Enter select - Q/ESC back to menu") + if choice is None: + return None + return choice == 0 + + # ------------------------------------------------------------ match + + def _play_match(self, vs_computer: bool) -> None: + board = Board() + computer = ComputerPlayer(PLAYER_TWO) if vs_computer else None + current = PLAYER_ONE + while True: + self._render(board) + if vs_computer and current == PLAYER_TWO: + term.status("Computer is thinking...", "dim") + column = computer.choose_column(board) + else: + column = self._ask_column(board, current, vs_computer) + if column is None: + return # player chose to leave the match + row = board.drop(column, current) + if row is None: + term.status("That column is full. Try another one.", "warn") + term.press_enter() + continue + if board.has_won(current): + self._render(board) + self._finish(board, current, vs_computer) + return + if board.is_full(): + self._render(board) + self._finish(board, EMPTY, vs_computer) + return + current = other_player(current) + + def _ask_column(self, board: Board, player: int, vs_computer: bool) -> int | None: + """Prompt for a column (1-7). None means the player quit.""" + name = player_name(player, vs_computer) + prompt = f"{name} ({colored_piece(player)}), choose a column (1-{COLUMN_COUNT}), H for help, Q to quit: " + while True: + answer = read_line(prompt) + if answer is None: + return None + answer = answer.strip().lower() + if answer == "q": + if term.confirm("Return to the main menu?"): + return None + self._render(board) + continue + if answer == "h": + self._show_help() + self._render(board) + continue + column = safe_int(answer) + if column is None or not 1 <= column <= COLUMN_COUNT: + term.status(f"Invalid move. Enter a number from 1 to {COLUMN_COUNT}.", "error") + continue + if not board.is_valid_column(column - 1): + term.status("That column is full. Try another one.", "warn") + continue + return column - 1 + + def _finish(self, board: Board, winner: int, vs_computer: bool) -> None: + store = get_store() + if winner == EMPTY: + term.status("The board is full. It's a draw!", "warn") + store.record_result(KEY, "draw") + elif vs_computer: + if winner == PLAYER_ONE: + term.status("You connected four. You win!", "ok") + store.record_result(KEY, "win") + else: + term.status("The computer connected four. You lose.", "error") + store.record_result(KEY, "loss") + else: + name = player_name(winner, vs_computer) + term.status(f"{name} connected four and wins the game!", "ok") + store.record_result(KEY, "win") + term.press_enter() + + # ------------------------------------------------------------ render + + def _render(self, board: Board) -> None: + term.clear() + print() + term.print_centered([ + term.style("CONNECT FOUR", term.Color.CYAN, term.Color.BOLD), + "", + ]) + cell = 4 + width = COLUMN_COUNT * cell + 1 + border = term.BOX + pad = max(0, (term.size()[0] - width) // 2) + + def line(text: str) -> None: + print(" " * pad + text) + + numbers = " ".join(f" {n} " for n in range(1, COLUMN_COUNT + 1)) + line(term.style(numbers, term.Color.CYAN)) + line(border["tl"] + border["tt"].join(border["h"] * (cell - 1) + for _ in range(COLUMN_COUNT)) + border["tr"]) + for row in range(ROW_COUNT): + cells = border["v"].join(f" {colored_piece(board.grid[row][c])} " + for c in range(COLUMN_COUNT)) + line(border["v"] + cells + border["v"]) + if row < ROW_COUNT - 1: + line(border["lt"] + border["x"].join(border["h"] * (cell - 1) + for _ in range(COLUMN_COUNT)) + border["rt"]) + line(border["bl"] + border["bt"].join(border["h"] * (cell - 1) + for _ in range(COLUMN_COUNT)) + border["br"]) + print() + legend = (f"{colored_piece(PLAYER_ONE)} Player 1 " + f"{colored_piece(PLAYER_TWO)} Player 2") + print(term.center(legend)) + print() + + # ------------------------------------------------------------ help + + def _show_help(self) -> None: + term.clear() + print() + lines = [ + term.style("CONNECT FOUR - HELP", term.Color.CYAN, term.Color.BOLD), + "", + term.style("Objective:", term.Color.CYAN), + "Be the first to connect four of your pieces in a row:", + "horizontally, vertically or diagonally.", + "", + term.style("Controls:", term.Color.CYAN), + f" 1-{COLUMN_COUNT} drop a piece into that column", + " H show this help screen", + " Q return to the main menu (asks for confirmation)", + "", + term.style("Rules:", term.Color.CYAN), + "Players take turns dropping one piece per turn.", + "Pieces fall to the lowest free cell of the column.", + "A column is full once it holds six pieces.", + "If the whole board fills with no four in a row,", + "the game is a draw.", + ] + for line in term.box(lines, pad=2): + print(term.center(line)) + print() + term.press_enter()
@@ -0,0 +1,323 @@+"""Fifteen Puzzle - the classic 4x4 sliding tile puzzle. + +Tiles 1-15 and one empty cell sit on a 4x4 grid. Sliding tiles +around, the player must restore the tiles to ascending order with +the empty cell in the bottom-right corner. + +Control convention: a direction key names the direction in which a +tile slides INTO the empty cell. Pressing UP (or W) slides the tile +below the gap upward into it, and so on. +""" +from __future__ import annotations + +import random +import time + +from core import terminal as term +from core.input import Key, read_key +from core.storage import get_store +from core.utils import format_time + +NAME = "Fifteen Puzzle" +KEY = "fifteen" +DESCRIPTION = "Slide the numbered tiles into order." + +RECORD_MOVES = "fifteen_best_moves" +RECORD_TIME = "fifteen_best_time" + +DIRECTIONS = ("UP", "DOWN", "LEFT", "RIGHT") +OPPOSITE = {"UP": "DOWN", "DOWN": "UP", "LEFT": "RIGHT", "RIGHT": "LEFT"} + +SCRAMBLE_MOVES = 200 +CELL_WIDTH = 4 + + +class Board: + """Pure 4x4 sliding-puzzle logic. No printing, no input. + + Tiles are stored row-major in a flat list of 16 integers; 0 is + the empty cell. A move is named after the direction the TILE + slides into the empty cell: "UP" moves the tile below the gap + up into it (the gap itself moves down). + """ + + SIZE = 4 + BLANK = 0 + + def __init__(self, tiles=None): + if tiles is None: + tiles = self.solved_tiles() + tiles = list(tiles) + expected = list(range(self.SIZE * self.SIZE)) + if len(tiles) != len(expected) or sorted(tiles) != expected: + raise ValueError("tiles must be a permutation of 0..15") + self.tiles = tiles + self.move_count = 0 + + # ---------------------------------------------------------- setup + + @classmethod + def solved_tiles(cls) -> list[int]: + """Tiles of the goal state: 1..15 in order, blank last.""" + return list(range(1, cls.SIZE * cls.SIZE)) + [cls.BLANK] + + @classmethod + def shuffled(cls, rng=None, scramble_moves: int = SCRAMBLE_MOVES) -> "Board": + """Build a board by applying random legal moves from the goal. + + Shuffling with legal moves guarantees a solvable position by + construction; immediate backtracking is avoided so the board + is genuinely mixed. + """ + rng = rng or random + board = cls() + previous = None + for _ in range(scramble_moves): + options = [d for d in board.legal_moves() if d != OPPOSITE[previous]] \ + if previous else board.legal_moves() + direction = rng.choice(options) + board.move(direction) + previous = direction + board.move_count = 0 + if board.is_solved(): # astronomically unlikely, but never hand back a solved board + return cls.shuffled(rng, scramble_moves) + return board + + # -------------------------------------------------------- queries + + def blank_index(self) -> int: + return self.tiles.index(self.BLANK) + + def blank_position(self) -> tuple[int, int]: + """(row, col) of the empty cell, 0-based.""" + index = self.blank_index() + return divmod(index, self.SIZE) + + def tile_at(self, row: int, col: int) -> int: + return self.tiles[row * self.SIZE + col] + + def is_solved(self) -> bool: + return self.tiles == self.solved_tiles() + + def legal_moves(self) -> list[str]: + return [d for d in DIRECTIONS if self.can_move(d)] + + def can_move(self, direction: str) -> bool: + """True when a tile can slide `direction` into the empty cell.""" + row, col = self.blank_position() + if direction == "UP": # tile below the gap slides up + return row < self.SIZE - 1 + if direction == "DOWN": # tile above the gap slides down + return row > 0 + if direction == "LEFT": # tile right of the gap slides left + return col < self.SIZE - 1 + if direction == "RIGHT": # tile left of the gap slides right + return col > 0 + return False + + # -------------------------------------------------------- changes + + def move(self, direction: str) -> bool: + """Slide a tile `direction` into the gap. False when impossible.""" + if not self.can_move(direction): + return False + index = self.blank_index() + if direction == "UP": + other = index + self.SIZE + elif direction == "DOWN": + other = index - self.SIZE + elif direction == "LEFT": + other = index + 1 + else: # RIGHT + other = index - 1 + self.tiles[index], self.tiles[other] = self.tiles[other], self.tiles[index] + self.move_count += 1 + return True + + # ---------------------------------------------------- solvability + + @staticmethod + def count_inversions(tiles) -> int: + """Pairs (i < j) with tiles[i] > tiles[j]; the blank is ignored.""" + sequence = [t for t in tiles if t != Board.BLANK] + inversions = 0 + for i in range(len(sequence)): + for j in range(i + 1, len(sequence)): + if sequence[i] > sequence[j]: + inversions += 1 + return inversions + + @staticmethod + def is_solvable(tiles) -> bool: + """Parity test for a 4-wide grid. + + Solvable iff inversions + row-of-blank-from-bottom is odd. + """ + tiles = list(tiles) + inversions = Board.count_inversions(tiles) + blank_row = tiles.index(Board.BLANK) // Board.SIZE + row_from_bottom = Board.SIZE - blank_row + return (inversions + row_from_bottom) % 2 == 1 + + +class Game: + """UI layer: rendering, input handling, statistics.""" + + def __init__(self): + self.store = get_store() + + def run(self) -> None: + term.hide_cursor() + try: + while True: + if not self._play_round(): + break # player quit mid-game + if not term.confirm("Play again?", default=True): + break + finally: + term.show_cursor() + + # -------------------------------------------------------- one game + + def _play_round(self) -> bool: + """Play one board. True when it was solved, False when quit.""" + board = Board.shuffled() + start = time.monotonic() + message = "" + while True: + self._draw(board, time.monotonic() - start, message) + message = "" + key = read_key() + direction = self._direction_for(key) + if direction is not None: + if board.move(direction): + if board.is_solved(): + self._handle_victory(board, time.monotonic() - start) + return True + else: + message = "No tile can slide that way." + elif key in (Key.ESCAPE,) or (isinstance(key, str) and key.lower() == "q"): + if term.confirm("Return to the main menu?"): + return False + elif isinstance(key, str) and key.lower() == "r": + board = Board.shuffled() + start = time.monotonic() + message = "Board reshuffled. Good luck!" + elif isinstance(key, str) and key.lower() == "h": + self._show_help() + # any other key is ignored + + @staticmethod + def _direction_for(key) -> str | None: + """Map an arrow key or WASD letter to a slide direction.""" + mapping = { + Key.UP: "UP", Key.DOWN: "DOWN", Key.LEFT: "LEFT", Key.RIGHT: "RIGHT", + "w": "UP", "s": "DOWN", "a": "LEFT", "d": "RIGHT", + } + if not isinstance(key, str): + return None + if key in mapping: + return mapping[key] + return mapping.get(key.lower()) + + def _handle_victory(self, board: Board, elapsed: float) -> None: + seconds = max(1, int(round(elapsed))) + self.store.record_result(KEY, "win") + best_moves = self.store.improve_record(RECORD_MOVES, board.move_count, True) + best_time = self.store.improve_record(RECORD_TIME, seconds, True) + self._draw(board, elapsed) + print() + term.print_centered([ + term.style("PUZZLE SOLVED! Congratulations!", term.Color.GREEN, term.Color.BOLD), + f"You finished in {board.move_count} moves and {format_time(seconds)}.", + ]) + if best_moves: + print(term.center(term.style("New record: fewest moves!", term.Color.YELLOW))) + if best_time: + print(term.center(term.style("New record: fastest time!", term.Color.YELLOW))) + print() + + # -------------------------------------------------------- drawing + + def _draw(self, board: Board, elapsed: float, message: str = "") -> None: + term.clear() + print() + print(term.center(term.style(NAME.upper(), term.Color.CYAN, term.Color.BOLD))) + print() + stats = (f"Moves: {board.move_count} Time: {format_time(elapsed)}") + print(term.center(stats)) + print(term.center(term.style(self._records_line(), term.Color.GRAY))) + print() + for line in self._board_lines(board): + print(term.center(line)) + print() + controls = "Arrows/WASD slide a tile - R reshuffle - H help - Q/ESC menu" + print(term.center(term.style(controls, term.Color.GRAY))) + if message: + print(term.center(term.style(message, term.Color.YELLOW))) + else: + print() + + def _records_line(self) -> str: + moves = self.store.get_record(RECORD_MOVES) + seconds = self.store.get_record(RECORD_TIME) + parts = [] + if moves is not None: + parts.append(f"best {moves} moves") + if seconds is not None: + parts.append(f"best time {format_time(seconds)}") + return "Records: " + ", ".join(parts) if parts else "No records yet." + + @staticmethod + def _board_lines(board: Board) -> list[str]: + """Render the grid using box-drawing chars (ASCII fallback).""" + b = term.BOX + size = Board.SIZE + segment = b["h"] * CELL_WIDTH + top = b["tl"] + b["tt"].join([segment] * size) + b["tr"] + middle = b["lt"] + b["x"].join([segment] * size) + b["rt"] + bottom = b["bl"] + b["bt"].join([segment] * size) + b["br"] + lines = [top] + for row in range(size): + cells = [] + for col in range(size): + value = board.tile_at(row, col) + if value == Board.BLANK: + cells.append(" " * CELL_WIDTH) + else: + label = f"{value:^{CELL_WIDTH}}" + in_place = value == row * size + col + 1 + color = term.Color.GREEN if in_place else term.Color.WHITE + cells.append(term.style(label, color)) + lines.append(b["v"] + b["v"].join(cells) + b["v"]) + lines.append(middle if row < size - 1 else bottom) + return lines + + # ----------------------------------------------------------- help + + def _show_help(self) -> None: + term.clear() + print() + lines = [ + term.style(f"{NAME} - HELP", term.Color.CYAN, term.Color.BOLD), + "", + term.style("OBJECTIVE", term.Color.CYAN), + "Order the tiles 1 to 15 from top-left to bottom-right,", + "leaving the empty cell in the bottom-right corner.", + "", + term.style("CONTROLS", term.Color.CYAN), + "Arrow keys or WASD : slide a tile into the empty cell", + " (the key names the direction the tile moves)", + "R : restart with a freshly shuffled board", + "H : show this help screen", + "Q or ESC : return to the main menu", + "", + term.style("RULES", term.Color.CYAN), + "Only a tile directly next to the empty cell can slide.", + "Every starting board is guaranteed to be solvable.", + "Fewer moves and faster times set new records.", + ] + for line in lines: + print(term.center(line)) + print() + term.press_enter()
@@ -0,0 +1,329 @@+"""2048 for Pablo's Terminal Games. + +Slide numbered tiles on a 4x4 board, merge equal values and build +the 2048 tile. Real-time input: arrow keys or WASD. +""" +from __future__ import annotations + +import random + +from core import terminal as term +from core.input import Key, read_key +from core.storage import get_store + +NAME = "2048" +KEY = "game_2048" +DESCRIPTION = "Slide and merge numbered tiles to reach 2048." + +DIRECTIONS = ("left", "right", "up", "down") + + +class Board: + """Pure 2048 logic on a 4x4 grid of ints (0 = empty). No I/O.""" + + SIZE = 4 + TARGET = 2048 + + def __init__(self, grid=None, rng=None): + self.rng = rng if rng is not None else random.Random() + self.score = 0 + if grid is not None: + self.grid = [list(row) for row in grid] + else: + self.grid = [[0] * self.SIZE for _ in range(self.SIZE)] + self.spawn_tile() + self.spawn_tile() + + # ------------------------------------------------------- movement + + @staticmethod + def merge_line(line): + """Slide one row/column toward index 0, merging equal neighbours. + + Each tile merges at most once per move. Returns a tuple + (new_line, points_gained); new_line has the same length. + """ + tiles = [value for value in line if value] + merged = [] + points = 0 + i = 0 + while i < len(tiles): + if i + 1 < len(tiles) and tiles[i] == tiles[i + 1]: + value = tiles[i] * 2 + merged.append(value) + points += value + i += 2 + else: + merged.append(tiles[i]) + i += 1 + merged.extend([0] * (len(line) - len(merged))) + return merged, points + + def move(self, direction): + """Slide all tiles in a direction. Returns True if the board changed. + + A valid move adds the merged values to the score and spawns + one new tile. An invalid move (nothing slides) changes nothing. + """ + if direction not in DIRECTIONS: + raise ValueError(f"unknown direction: {direction!r}") + lines = self._lines(direction) + changed = False + gained = 0 + new_lines = [] + for line in lines: + merged, points = self.merge_line(line) + if merged != line: + changed = True + gained += points + new_lines.append(merged) + if not changed: + return False + self._put_lines(direction, new_lines) + self.score += gained + self.spawn_tile() + return True + + def _lines(self, direction): + """Rows or columns oriented so index 0 is the slide target.""" + g, n = self.grid, self.SIZE + if direction == "left": + return [row[:] for row in g] + if direction == "right": + return [row[::-1] for row in g] + if direction == "up": + return [[g[r][c] for r in range(n)] for c in range(n)] + return [[g[r][c] for r in range(n - 1, -1, -1)] for c in range(n)] # down + + def _put_lines(self, direction, lines): + """Write lines produced by _lines back into the grid.""" + n = self.SIZE + for i, line in enumerate(lines): + if direction == "left": + self.grid[i] = line + elif direction == "right": + self.grid[i] = line[::-1] + elif direction == "up": + for r in range(n): + self.grid[r][i] = line[r] + else: # down + for r in range(n): + self.grid[n - 1 - r][i] = line[r] + + # ---------------------------------------------------------- state + + def empty_cells(self): + """List of (row, col) coordinates holding a zero.""" + return [(r, c) for r in range(self.SIZE) for c in range(self.SIZE) + if self.grid[r][c] == 0] + + def spawn_tile(self): + """Place a 2 (90% chance) or a 4 (10%) in a random empty cell. + + Returns the (row, col) used, or None when the board is full. + """ + cells = self.empty_cells() + if not cells: + return None + row, col = self.rng.choice(cells) + self.grid[row][col] = 2 if self.rng.random() < 0.9 else 4 + return (row, col) + + def highest_tile(self): + """Largest value currently on the board.""" + return max(max(row) for row in self.grid) + + def has_won(self): + """True once the 2048 tile (or bigger) exists.""" + return self.highest_tile() >= self.TARGET + + def has_moves(self): + """True while any slide would change the board.""" + if self.empty_cells(): + return True + n = self.SIZE + for r in range(n): + for c in range(n): + value = self.grid[r][c] + if r + 1 < n and self.grid[r + 1][c] == value: + return True + if c + 1 < n and self.grid[r][c + 1] == value: + return True + return False + + def is_game_over(self): + """True when the board is full and no equal neighbours remain.""" + return not self.has_moves() + + +class Game: + """Interactive UI layer on top of Board.""" + + CELL = 7 # character width of one board cell + + _MOVE_KEYS = { + Key.UP: "up", Key.DOWN: "down", Key.LEFT: "left", Key.RIGHT: "right", + "w": "up", "s": "down", "a": "left", "d": "right", + } + + def __init__(self): + self.store = get_store() + self.board = Board() + self.message = "" + self._win_announced = False + + def run(self): + """Play complete sessions of 2048 until the player leaves.""" + term.hide_cursor() + try: + while self._play_one_game(): + pass + finally: + term.show_cursor() + + # ------------------------------------------------------ game loop + + def _play_one_game(self): + """Play one board. Returns True to play again, False for the menu.""" + self.board = Board() + self.message = "" + self._win_announced = False + while True: + self._render() + if self.board.is_game_over(): + return self._game_over() + if not self._win_announced and self.board.has_won(): + self._win_announced = True + self.message = "You reached 2048! Keep going for a higher score." + continue + action = self._handle_key(read_key()) + if action == "quit": + return False + if action == "restart": + return True + + def _handle_key(self, key): + """Handle one pressed key. Returns 'quit', 'restart' or None.""" + low = key.lower() if isinstance(key, str) and len(key) == 1 else key + if low in self._MOVE_KEYS: + if self.board.move(self._MOVE_KEYS[low]): + self.message = "" + self._save_records() + else: + self.message = "Nothing can slide that way." + elif low == "r": + if term.confirm("Restart with a fresh board?"): + return "restart" + elif low == "h": + self._show_help() + self.message = "" + elif low in ("q", Key.ESCAPE): + if term.confirm("Return to the main menu?"): + self._quit_game() + return "quit" + else: + self.message = "Unknown key. Press H for help." + return None + + def _game_over(self): + outcome = "win" if self.board.has_won() else "loss" + self.store.record_result(KEY, outcome) + self._save_records() + self._render() + print() + text = f"Game over! Final score: {self.board.score}." + term.print_centered([term.style(text, term.Color.YELLOW)]) + print() + return term.confirm("Play again?", default=True) + + def _quit_game(self): + """Leaving mid-game: keep the records; a made 2048 counts as a win.""" + self._save_records() + if self.board.has_won(): + self.store.record_result(KEY, "win") + + def _save_records(self): + self.store.improve_record("2048_high_score", self.board.score) + self.store.improve_record("2048_best_tile", self.board.highest_tile()) + + # ------------------------------------------------------- rendering + + def _render(self): + term.clear() + print() + title = term.style("2048", term.Color.CYAN, term.Color.BOLD) + best = self.store.get_record("2048_high_score", 0) or 0 + info = (f"Score: {self.board.score} Best: {best} " + f"Highest tile: {self.board.highest_tile()}") + term.print_centered([title, "", info, ""]) + for line in self._board_lines(): + print(term.center(line)) + print() + if self.message: + term.print_centered([term.style(self.message, term.Color.YELLOW)]) + hint = "Arrows/WASD slide - R restart - H help - Q menu" + term.print_centered([term.style(hint, term.Color.GRAY)]) + + def _board_lines(self): + b = term.BOX + n = Board.SIZE + segment = b["h"] * self.CELL + top = b["tl"] + b["tt"].join([segment] * n) + b["tr"] + middle = b["lt"] + b["x"].join([segment] * n) + b["rt"] + bottom = b["bl"] + b["bt"].join([segment] * n) + b["br"] + lines = [top] + for r, row in enumerate(self.board.grid): + cells = b["v"].join(self._cell_text(value) for value in row) + lines.append(b["v"] + cells + b["v"]) + lines.append(bottom if r == n - 1 else middle) + return lines + + def _cell_text(self, value): + if not value: + return " " * self.CELL + text = str(value) + pad = self.CELL - len(text) + left = pad // 2 + styled = term.style(text, *self._tile_colors(value)) + return " " * left + styled + " " * (pad - left) + + @staticmethod + def _tile_colors(value): + if value >= 512: + return (term.Color.RED, term.Color.BOLD) + if value >= 128: + return (term.Color.YELLOW,) + if value >= 32: + return (term.Color.GREEN,) + if value >= 8: + return (term.Color.CYAN,) + return (term.Color.WHITE,) + + # ------------------------------------------------------------ help + + def _show_help(self): + term.clear() + print() + term.print_centered([term.style("2048 - HELP", term.Color.CYAN, term.Color.BOLD), ""]) + lines = [ + term.style("Objective:", term.Color.CYAN), + " Slide the numbered tiles and merge equal values", + " to build a tile worth 2048 (then keep going!).", + "", + term.style("Controls:", term.Color.CYAN), + " Arrow keys or WASD slide every tile that way", + " R restart with a fresh board", + " H show this help screen", + " Q or ESC return to the main menu", + "", + term.style("Rules:", term.Color.CYAN), + " Two equal tiles that collide merge into their double,", + " once per move, and the merged value adds to your score.", + " After every valid move a new tile appears: 90% a 2,", + " 10% a 4. The game ends when the board is full and no", + " equal neighbours remain.", + ] + for line in term.box(lines, pad=2): + print(term.center(line)) + print() + term.press_enter()
@@ -0,0 +1,380 @@+#!/usr/bin/env python3 +"""Gomoku (Five in a Row) on a 15x15 board. + +Two players alternate placing stones; the first to line up five or +more consecutive stones (horizontally, vertically or diagonally) wins. +Overlines (six or more) also count as a win. + +Pure game logic lives in GomokuBoard and GomokuAI (no printing, no +input) so tests can drive them headless. Game is the terminal UI. +""" +from __future__ import annotations + +import random +import re + +from core import terminal as term +from core.input import read_line +from core.menu import choose +from core.storage import get_store + +NAME = "Gomoku" +KEY = "gomoku" +DESCRIPTION = "Five in a row on a 15x15 board, against the computer or a friend." + +SIZE = 15 +WIN_LENGTH = 5 +EMPTY, X, O = 0, 1, 2 +COLUMN_LETTERS = "ABCDEFGHIJKLMNO" +DIRECTIONS = ((0, 1), (1, 0), (1, 1), (1, -1)) + +# Values for one line of stones, keyed by (length, open ends). +LINE_VALUES = { + (5, 2): 1_000_000, (5, 1): 1_000_000, (5, 0): 1_000_000, + (4, 2): 100_000, (4, 1): 10_000, (4, 0): 0, + (3, 2): 5_000, (3, 1): 500, (3, 0): 0, + (2, 2): 200, (2, 1): 50, (2, 0): 0, + (1, 2): 10, (1, 1): 2, (1, 0): 0, +} + + +class GomokuBoard: + """The 15x15 board and the rules of Gomoku. No UI in here.""" + + def __init__(self, size: int = SIZE): + self.size = size + self.grid = [[EMPTY] * size for _ in range(size)] + self.moves: list[tuple[int, int, int]] = [] + self.current_player = X + self.winner = EMPTY + + # ------------------------------------------------------------ queries + + def in_bounds(self, row: int, col: int) -> bool: + return 0 <= row < self.size and 0 <= col < self.size + + def is_empty(self, row: int, col: int) -> bool: + return self.in_bounds(row, col) and self.grid[row][col] == EMPTY + + def is_full(self) -> bool: + return all(cell != EMPTY for row in self.grid for cell in row) + + def is_over(self) -> bool: + return self.winner != EMPTY or self.is_full() + + def count_direction(self, row: int, col: int, dr: int, dc: int, player: int) -> int: + """Consecutive stones of player starting one step from (row, col).""" + count = 0 + r, c = row + dr, col + dc + while self.in_bounds(r, c) and self.grid[r][c] == player: + count += 1 + r += dr + c += dc + return count + + def wins_through(self, row: int, col: int) -> bool: + """True if the stone at (row, col) is part of five or more in a row.""" + player = self.grid[row][col] + if player == EMPTY: + return False + for dr, dc in DIRECTIONS: + total = 1 + total += self.count_direction(row, col, dr, dc, player) + total += self.count_direction(row, col, -dr, -dc, player) + if total >= WIN_LENGTH: + return True + return False + + # ------------------------------------------------------------ moves + + def place(self, row: int, col: int) -> bool: + """Place the current player's stone. False when the move is illegal.""" + if self.winner != EMPTY or not self.is_empty(row, col): + return False + player = self.current_player + self.grid[row][col] = player + self.moves.append((row, col, player)) + if self.wins_through(row, col): + self.winner = player + else: + self.current_player = O if player == X else X + return True + + # ------------------------------------------------------------ coordinates + + @staticmethod + def parse_coordinate(text: str, size: int = SIZE) -> tuple[int, int] | None: + """Parse 'h8' style input (column letter + row number) to (row, col).""" + match = re.fullmatch(r"([A-Za-z])\s*([0-9]{1,2})", (text or "").strip()) + if not match: + return None + col = ord(match.group(1).upper()) - ord("A") + row = int(match.group(2)) - 1 + if 0 <= row < size and 0 <= col < size: + return (row, col) + return None + + @staticmethod + def format_coordinate(row: int, col: int) -> str: + return f"{COLUMN_LETTERS[col]}{row + 1}" + + +class GomokuAI: + """Simple scored-search opponent. No minimax, just priorities: + + 1. complete its own five, + 2. block an immediate opponent win, + 3. extend useful sequences (open threes, fours) / block the opponent's, + 4. otherwise play near existing stones with a slight center bias. + """ + + CANDIDATE_RANGE = 2 + + def __init__(self, player: int = O): + self.player = player + + def opponent(self) -> int: + return X if self.player == O else O + + def choose_move(self, board: GomokuBoard) -> tuple[int, int] | None: + """Pick a move for the AI on the given board.""" + if board.is_over(): + return None + move = self._winning_move(board, self.player) + if move: + return move + move = self._winning_move(board, self.opponent()) + if move: + return move + candidates = self._candidates(board) + if not candidates: + center = board.size // 2 + return (center, center) + best_score = max(self._cell_score(board, r, c) for r, c in candidates) + best = [(r, c) for r, c in candidates + if self._cell_score(board, r, c) == best_score] + return random.choice(best) + + # ------------------------------------------------------------ helpers + + def _winning_move(self, board: GomokuBoard, player: int) -> tuple[int, int] | None: + """First empty cell that immediately gives player five in a row.""" + for row in range(board.size): + for col in range(board.size): + if board.grid[row][col] != EMPTY: + continue + board.grid[row][col] = player + wins = board.wins_through(row, col) + board.grid[row][col] = EMPTY + if wins: + return (row, col) + return None + + def _candidates(self, board: GomokuBoard) -> list[tuple[int, int]]: + """Empty cells close to existing stones (worth evaluating).""" + if not board.moves: + return [] + reach = self.CANDIDATE_RANGE + cells = set() + for row, col, _ in board.moves: + for r in range(max(0, row - reach), min(board.size, row + reach + 1)): + for c in range(max(0, col - reach), min(board.size, col + reach + 1)): + if board.grid[r][c] == EMPTY: + cells.add((r, c)) + return sorted(cells) + + def _cell_score(self, board: GomokuBoard, row: int, col: int) -> int: + """Attack value for the AI plus defensive value against the opponent, + with a small bonus for central cells.""" + attack = self._line_score(board, row, col, self.player) + defense = self._line_score(board, row, col, self.opponent()) + center = board.size // 2 + center_bonus = board.size - (abs(row - center) + abs(col - center)) + return attack + defense + center_bonus + + def _line_score(self, board: GomokuBoard, row: int, col: int, player: int) -> int: + """Value of placing player's stone at (row, col), summed over all + four directions (open-ended lines are worth far more).""" + total = 0 + for dr, dc in DIRECTIONS: + forward = board.count_direction(row, col, dr, dc, player) + backward = board.count_direction(row, col, -dr, -dc, player) + length = 1 + forward + backward + open_ends = 0 + if board.is_empty(row + (forward + 1) * dr, col + (forward + 1) * dc): + open_ends += 1 + if board.is_empty(row - (backward + 1) * dr, col - (backward + 1) * dc): + open_ends += 1 + length = min(length, WIN_LENGTH) + total += LINE_VALUES.get((length, open_ends), 0) + return total + + +class Game: + """Interactive terminal UI for Gomoku.""" + + def __init__(self): + self.store = get_store() + self.message = "" + + # ------------------------------------------------------------ session + + def run(self) -> None: + vs_computer = self._choose_mode() + if vs_computer is None: + return + while True: + self._play_match(vs_computer) + if not term.confirm("Play again?", default=True): + return + + def _choose_mode(self) -> bool | None: + """True = Player vs Computer, False = Player vs Player, None = quit.""" + title = [ + term.style("GOMOKU", term.Color.CYAN, term.Color.BOLD), + term.style(DESCRIPTION, term.Color.GRAY), + ] + options = ["Player vs Computer", "Player vs Player"] + choice = choose(title, options, hint="Enter select - Q back to menu") + if choice is None: + return None + return choice == 0 + + # ------------------------------------------------------------ one match + + def _play_match(self, vs_computer: bool) -> None: + board = GomokuBoard() + ai = GomokuAI(O) if vs_computer else None + self.message = "" + while not board.is_over(): + self._render(board, vs_computer) + if vs_computer and board.current_player == O: + row, col = ai.choose_move(board) + board.place(row, col) + self.message = f"Computer plays {GomokuBoard.format_coordinate(row, col)}." + continue + if not self._human_turn(board, vs_computer): + return # aborted by the player: no statistics + self._render(board, vs_computer) + self._finish(board, vs_computer) + + def _human_turn(self, board: GomokuBoard, vs_computer: bool) -> bool: + """One human move. Returns False when the player quits the match.""" + name = self._player_name(board.current_player, vs_computer) + stone = self._stone_char(board.current_player) + prompt = f"{name} ({stone}), your move [h=help, q=quit]: " + answer = read_line(prompt) + if answer is None: + answer = "q" + command = answer.strip().lower() + if command == "q": + if term.confirm("Return to the main menu?"): + return False + self.message = "" + return True + if command == "h": + self._show_help() + self.message = "" + return True + coord = GomokuBoard.parse_coordinate(answer) + if coord is None: + self.message = "Invalid move. Use a column letter and row number, e.g. h8." + return True + if not board.place(*coord): + self.message = "That square is already occupied." + return True + self.message = "" + return True + + def _finish(self, board: GomokuBoard, vs_computer: bool) -> None: + if board.winner == EMPTY: + term.status("The board is full. It's a draw!", "warn") + self.store.record_result(KEY, "draw") + else: + name = self._player_name(board.winner, vs_computer) + stone = self._stone_char(board.winner) + term.status(f"{name} ({stone}) wins with five in a row!", "ok") + if vs_computer: + self.store.record_result(KEY, "win" if board.winner == X else "loss") + else: + self.store.record_result(KEY, "win") + term.press_enter() + + # ------------------------------------------------------------ rendering + + def _render(self, board: GomokuBoard, vs_computer: bool) -> None: + term.clear() + print() + mode = "Player vs Computer" if vs_computer else "Player vs Player" + print(term.center(term.style(f"GOMOKU - {mode}", term.Color.CYAN, term.Color.BOLD))) + print() + lines = self._board_lines(board) + width = max(term.visible_len(line) for line in lines) + pad = max(0, (term.size()[0] - width) // 2) + for line in lines: + print(" " * pad + line) + print() + if not board.is_over(): + name = self._player_name(board.current_player, vs_computer) + stone = self._stone_char(board.current_player) + print(term.center(f"{name} to move ({stone})")) + if self.message: + kind = "info" if self.message.startswith("Computer") else "warn" + print(term.center(term.style(self.message, term.STATUS_COLORS[kind]))) + print() + + def _board_lines(self, board: GomokuBoard) -> list[str]: + header = " " + " ".join(COLUMN_LETTERS[:board.size]) + lines = [term.style(header, term.Color.GRAY)] + for row in range(board.size): + cells = [] + for col in range(board.size): + player = board.grid[row][col] + if player == EMPTY: + cells.append(term.style("·" if term.UNICODE else ".", term.Color.GRAY)) + elif player == X: + cells.append(term.style(self._stone_char(X), term.Color.CYAN, term.Color.BOLD)) + else: + cells.append(term.style(self._stone_char(O), term.Color.YELLOW)) + lines.append(f"{row + 1:>3} " + " ".join(cells)) + return lines + + @staticmethod + def _stone_char(player: int) -> str: + if term.UNICODE: + return "●" if player == X else "○" + return "X" if player == X else "O" + + @staticmethod + def _player_name(player: int, vs_computer: bool) -> str: + if vs_computer: + return "You" if player == X else "Computer" + return "Player 1" if player == X else "Player 2" + + # ------------------------------------------------------------ help + + def _show_help(self) -> None: + term.clear() + print() + lines = [ + term.style("GOMOKU - HELP", term.Color.CYAN, term.Color.BOLD), + "", + term.style("OBJECTIVE", term.Color.CYAN), + "Be the first to line up FIVE or more of your stones", + "in a row: horizontally, vertically or diagonally.", + "Longer lines (overlines) also win.", + "", + term.style("CONTROLS", term.Color.CYAN), + "Type a coordinate as column letter + row number,", + "for example h8, and press Enter.", + "h - show this help q - quit to the main menu", + "", + term.style("RULES", term.Color.CYAN), + "Players alternate placing one stone on an empty", + "intersection. Player 1 (X) always moves first.", + "If the board fills with no five in a row, it's a draw.", + ] + for line in term.box(lines, pad=2): + print(term.center(line)) + print() + term.press_enter()
@@ -0,0 +1,393 @@+"""Mancala (Kalah) for Pablo's Terminal Games. + +Traditional Kalah rules: six pits per side with four stones each and +one store per player. On a turn the player picks one of their pits and +sows its stones counter-clockwise, one per pit, including their own +store but skipping the opponent's store. A last stone in the own store +grants an extra turn; a last stone in an empty own pit captures that +stone plus the opposite pit's stones. The game ends when one side is +empty; the remaining stones go to the other player's store and the +higher store wins. + +Pure game logic lives in KalahBoard and ComputerPlayer (no printing, +no input); the Game class is the interactive UI layer on top. +""" +from __future__ import annotations + +import random +from typing import NamedTuple + +from core import terminal as term +from core.input import read_line +from core.menu import choose +from core.storage import get_store +from core.utils import safe_int + +NAME = "Mancala" +KEY = "mancala" +DESCRIPTION = "Classic Kalah: sow stones, earn extra turns and capture pits." + +PITS_PER_SIDE = 6 +INITIAL_STONES = 4 + +HUMAN = 0 # bottom row, store on the right +OPPONENT = 1 # top row, store on the left +DRAW = -1 + +SLOT_COUNT = (PITS_PER_SIDE + 1) * 2 # 6 pits + store, per player + + +class MoveResult(NamedTuple): + """What happened during one sowing.""" + + pit: int # pit number played (1..PITS_PER_SIDE) + extra_turn: bool # last stone landed in the own store + captured: int # stones captured into the store (0 = no capture) + store_gain: int # total stones added to the own store this move + game_over: bool # the move ended the game + + +class KalahBoard: + """Pure Kalah game state. No printing, no input. + + The board is a flat list of 14 slots: + + 0-5 HUMAN's pits (left to right as the human sees them) + 6 HUMAN's store + 7-12 OPPONENT's pits (in sowing order, i.e. reversed on screen) + 13 OPPONENT's store + + Sowing moves towards higher slot numbers and wraps around, + skipping the opponent's store. The slot opposite pit slot ``i`` + is ``12 - i``. Pit numbers in the public API are 1-based (1..6), + matching what players type. + """ + + def __init__(self, slots=None, current: int = HUMAN): + if slots is None: + slots = ([INITIAL_STONES] * PITS_PER_SIDE + [0]) * 2 + if len(slots) != SLOT_COUNT: + raise ValueError(f"slots must have {SLOT_COUNT} entries") + self.slots = list(slots) + self.current = current + self.game_over = False + self.winner = None # HUMAN, OPPONENT or DRAW once game_over + + # ------------------------------------------------------------ indexing + + @staticmethod + def store_slot(player: int) -> int: + return PITS_PER_SIDE if player == HUMAN else SLOT_COUNT - 1 + + @staticmethod + def pit_slot(player: int, pit: int) -> int: + """Slot index of pit number ``pit`` (1..6) for ``player``.""" + return pit - 1 if player == HUMAN else PITS_PER_SIDE + pit + + @staticmethod + def opposite_slot(slot: int) -> int: + return (SLOT_COUNT - 2) - slot + + def _is_own_pit(self, player: int, slot: int) -> bool: + if player == HUMAN: + return 0 <= slot < PITS_PER_SIDE + return PITS_PER_SIDE < slot < SLOT_COUNT - 1 + + # ------------------------------------------------------------ queries + + def pits(self, player: int) -> list[int]: + """The player's six pits, in pit-number order (1..6).""" + start = 0 if player == HUMAN else PITS_PER_SIDE + 1 + return self.slots[start:start + PITS_PER_SIDE] + + def store(self, player: int) -> int: + return self.slots[self.store_slot(player)] + + def legal_moves(self, player: int | None = None) -> list[int]: + """Pit numbers (1..6) that may be played: non-empty own pits.""" + if player is None: + player = self.current + return [pit for pit, stones in enumerate(self.pits(player), start=1) + if stones > 0] + + def clone(self) -> "KalahBoard": + copy = KalahBoard(self.slots, self.current) + copy.game_over = self.game_over + copy.winner = self.winner + return copy + + # ------------------------------------------------------------ rules + + def apply_move(self, pit: int) -> MoveResult: + """Sow the stones of pit ``pit`` (1..6) for the current player. + + Raises ValueError for an illegal move. Returns a MoveResult. + """ + if self.game_over: + raise ValueError("The game is over.") + if pit not in self.legal_moves(): + raise ValueError(f"Illegal move: pit {pit}.") + + player = self.current + own_store = self.store_slot(player) + opponent_store = self.store_slot(1 - player) + + slot = self.pit_slot(player, pit) + stones = self.slots[slot] + self.slots[slot] = 0 + + store_gain = 0 + while stones: + slot = (slot + 1) % SLOT_COUNT + if slot == opponent_store: + continue # never sow into the opponent's store + self.slots[slot] += 1 + stones -= 1 + if slot == own_store: + store_gain += 1 + + extra_turn = slot == own_store + + captured = 0 + if (not extra_turn and self._is_own_pit(player, slot) + and self.slots[slot] == 1): # was empty before the last stone + opposite = self.opposite_slot(slot) + if self.slots[opposite] > 0: + captured = self.slots[opposite] + 1 + self.slots[own_store] += captured + store_gain += captured + self.slots[opposite] = 0 + self.slots[slot] = 0 + + self._check_end() + if not extra_turn: + self.current = 1 - player + return MoveResult(pit, extra_turn, captured, store_gain, self.game_over) + + def _check_end(self) -> None: + """End the game when a side is empty; collect remaining stones.""" + human_empty = all(n == 0 for n in self.pits(HUMAN)) + opponent_empty = all(n == 0 for n in self.pits(OPPONENT)) + if not (human_empty or opponent_empty): + return + for player in (HUMAN, OPPONENT): + store = self.store_slot(player) + for slot in range(SLOT_COUNT): + if self._is_own_pit(player, slot) and self.slots[slot]: + self.slots[store] += self.slots[slot] + self.slots[slot] = 0 + self.game_over = True + human_store, opponent_store = self.store(HUMAN), self.store(OPPONENT) + if human_store > opponent_store: + self.winner = HUMAN + elif opponent_store > human_store: + self.winner = OPPONENT + else: + self.winner = DRAW + + +class ComputerPlayer: + """Heuristic opponent. + + Scores every legal move on a cloned board and prefers, in order: + moves granting an extra turn, then captures, then the move putting + the most stones into its store. Ties are broken randomly. + """ + + def __init__(self, rng: random.Random | None = None): + self.rng = rng or random.Random() + + def choose_move(self, board: KalahBoard) -> int: + """Return the pit number (1..6) the computer wants to play.""" + best_score = None + best_moves: list[int] = [] + for pit in board.legal_moves(OPPONENT): + clone = board.clone() + clone.current = OPPONENT # simulate even if it is not its turn + result = clone.apply_move(pit) + score = (result.extra_turn, result.captured, result.store_gain) + if best_score is None or score > best_score: + best_score, best_moves = score, [pit] + elif score == best_score: + best_moves.append(pit) + return self.rng.choice(best_moves) + + +# ------------------------------------------------------------------ UI + +HELP_LINES = [ + "OBJECTIVE", + " Collect more stones in your store than your opponent.", + "", + "CONTROLS", + f" Type a pit number (1-{PITS_PER_SIDE}) and press Enter to sow it.", + " H shows this help, Q (or ESC) returns to the main menu.", + "", + "RULES", + " - Stones are sown counter-clockwise, one per pit, into your", + " own store but never into the opponent's store.", + " - Last stone in your own store: take another turn.", + " - Last stone in one of your empty pits: capture that stone", + " plus all stones in the opposite pit into your store.", + " - The game ends when one side is empty; the stones left on", + " the other side go to that player's store. Higher store wins.", +] + + +class Game: + """Interactive Mancala session: mode selection, play loop, replay.""" + + def __init__(self): + self.store = get_store() + self.computer = ComputerPlayer() + + # ------------------------------------------------------------ session + + def run(self) -> None: + title = [term.style("MANCALA", term.Color.CYAN, term.Color.BOLD), + term.style(DESCRIPTION, term.Color.GRAY)] + options = ["Player vs Computer", "Player vs Player", "How to Play"] + while True: + choice = choose(title, options, hotkeys=["1", "2", "3"], + hint="Enter select - Q/ESC back to the main menu") + if choice is None: + return + if choice == 2: + self._show_help() + continue + mode = "pvc" if choice == 0 else "pvp" + self._play_session(mode) + + def _play_session(self, mode: str) -> None: + """Play matches of one mode until the player declines a replay.""" + while self._play_match(mode): + if not term.confirm("Play again?"): + return + + # ------------------------------------------------------------ match + + def _play_match(self, mode: str) -> bool: + """Play one match. Returns False when the player quit mid-game.""" + board = KalahBoard() + names = ("PLAYER", "COMPUTER") if mode == "pvc" else ("PLAYER 1", "PLAYER 2") + message = "New game. Good luck!" + while not board.game_over: + self._render(board, names, message) + player = board.current + if mode == "pvc" and player == OPPONENT: + pit = self.computer.choose_move(board) + result = board.apply_move(pit) + message = self._describe_move(names[player], result) + continue + pit = self._ask_move(board, names, message) + if pit is None: + if term.confirm("Return to the main menu?"): + return False + message = "Game resumed." + continue + result = board.apply_move(pit) + message = self._describe_move(names[player], result) + self._render(board, names, message) + self._show_result(board, names, mode) + return True + + def _ask_move(self, board: KalahBoard, names, message: str) -> int | None: + """Prompt for a pit number. Returns 1..6, or None to quit.""" + player = board.current + prompt = (f"{names[player]}, choose a pit " + f"(1-{PITS_PER_SIDE}, H help, Q quit): ") + while True: + raw = read_line(prompt) + if raw is None: + return None + text = raw.strip().lower() + if text in ("q", "\x1b"): + return None + if text == "h": + self._show_help() + self._render(board, names, message) + continue + pit = safe_int(text) + if pit is None or not 1 <= pit <= PITS_PER_SIDE: + term.status(f"Invalid move. Enter a number from 1 to {PITS_PER_SIDE}.", + "warn") + continue + if pit not in board.legal_moves(): + term.status("That pit is empty. Choose another one.", "warn") + continue + return pit + + # ------------------------------------------------------------ screens + + def _render(self, board: KalahBoard, names, message: str) -> None: + term.clear() + print() + colors = {board.current: term.Color.GREEN, 1 - board.current: term.Color.GRAY} + for line in self._board_lines(board, names, colors): + print(term.center(line)) + print() + if message: + print(term.center(term.style(message, term.Color.CYAN))) + print() + + def _board_lines(self, board: KalahBoard, names, colors) -> list[str]: + """Classic layout: opponent on top (reversed), stores on the sides.""" + cell = "{:>3}".format + top = [cell(n) for n in reversed(board.pits(OPPONENT))] + bottom = [cell(n) for n in board.pits(HUMAN)] + labels = "".join(f" ({i})" for i in range(1, PITS_PER_SIDE + 1)) + row_indent = " " + store_row = (f" {cell(board.store(OPPONENT))} " + + " " * (PITS_PER_SIDE * 5) + + f" {cell(board.store(HUMAN))} ") + return [ + term.style(names[OPPONENT], colors[OPPONENT], term.Color.BOLD), + row_indent + labels, + row_indent + "".join(f" {n}" for n in top), + store_row, + row_indent + "".join(f" {n}" for n in bottom), + row_indent + labels, + term.style(names[HUMAN], colors[HUMAN], term.Color.BOLD), + term.style(f"{names[OPPONENT]}'s store is on the left, " + f"{names[HUMAN]}'s store is on the right.", term.Color.GRAY), + ] + + def _show_help(self) -> None: + term.clear() + print() + term.print_centered([term.style("MANCALA - HOW TO PLAY", + term.Color.CYAN, term.Color.BOLD), ""]) + for line in HELP_LINES: + print(term.center(line if line else "")) + print() + term.press_enter() + + def _show_result(self, board: KalahBoard, names, mode: str) -> None: + human_store = board.store(HUMAN) + opponent_store = board.store(OPPONENT) + score = f"Final score: {names[HUMAN]} {human_store} - " \ + f"{opponent_store} {names[OPPONENT]}" + print() + if board.winner == DRAW: + term.status(f"It's a draw! {score}", "warn") + self.store.record_result(KEY, "draw") + elif mode == "pvp": + term.status(f"{names[board.winner]} wins! {score}", "ok") + self.store.record_result(KEY, "win") + elif board.winner == HUMAN: + term.status(f"You win! {score}", "ok") + self.store.record_result(KEY, "win") + else: + term.status(f"The computer wins. {score}", "error") + self.store.record_result(KEY, "loss") + print() + term.press_enter() + + @staticmethod + def _describe_move(player_name: str, result: MoveResult) -> str: + parts = [f"{player_name} sows pit {result.pit}."] + if result.captured: + parts.append(f"Captures {result.captured} stones!") + if result.extra_turn: + parts.append("Extra turn!") + return " ".join(parts)
@@ -0,0 +1,394 @@+#!/usr/bin/env python3 +"""Minesweeper - clear the field without detonating a mine. + +Pure logic lives in Board (no printing, no input, no clock) so it can +be tested headless. The Game class is the real-time terminal UI layer. +""" +from __future__ import annotations + +import random +import time +from dataclasses import dataclass + +from core import terminal as term +from core.input import Key, read_key +from core.menu import choose +from core.storage import get_store +from core.utils import format_time + +NAME = "Minesweeper" +KEY = "minesweeper" +DESCRIPTION = "Clear the field without detonating a mine." + +CELL_WIDTH = 3 # characters per board cell when rendering + + +@dataclass(frozen=True) +class Difficulty: + """Immutable board preset.""" + + name: str + rows: int + cols: int + mines: int + record_key: str + + +DIFFICULTIES = { + "easy": Difficulty("Easy", 9, 9, 10, "minesweeper_best_time_easy"), + "medium": Difficulty("Medium", 16, 16, 40, "minesweeper_best_time_medium"), + "hard": Difficulty("Hard", 18, 22, 85, "minesweeper_best_time_hard"), +} +DIFFICULTY_ORDER = ("easy", "medium", "hard") + + +class Board: + """Pure minesweeper logic: mine placement, counts, reveal, flags. + + Mines are generated on the first reveal (or by an explicit + place_mines call); the first revealed cell and its neighbors are + guaranteed mine-free. No printing, no input, no clock. + """ + + def __init__(self, rows, cols, mine_count=0, mines=None, rng=None): + if rows < 2 or cols < 2: + raise ValueError("The board must be at least 2x2.") + self.rows = rows + self.cols = cols + self._rng = rng if rng is not None else random.Random() + if mines is not None: + mine_set = set(mines) + for cell in mine_set: + if not self._in_bounds(*cell): + raise ValueError(f"Mine outside the board: {cell}") + self.mine_count = len(mine_set) + self.mines = mine_set + self.mines_placed = True + else: + if mine_count < 1 or mine_count >= rows * cols: + raise ValueError("Invalid mine count for this board size.") + self.mine_count = mine_count + self.mines = set() + self.mines_placed = False + self.revealed = set() + self.flagged = set() + self.lost = False + self.exploded_at = None + + # ---------------------------------------------------------- helpers + + def _in_bounds(self, r, c): + return 0 <= r < self.rows and 0 <= c < self.cols + + def neighbors(self, r, c): + """Yield the in-bounds neighbors of cell (r, c).""" + for dr in (-1, 0, 1): + for dc in (-1, 0, 1): + if dr == 0 and dc == 0: + continue + nr, nc = r + dr, c + dc + if self._in_bounds(nr, nc): + yield (nr, nc) + + def neighbor_mine_count(self, r, c): + """How many mines touch cell (r, c).""" + return sum(1 for nb in self.neighbors(r, c) if nb in self.mines) + + # ---------------------------------------------------------- mines + + def place_mines(self, safe_r, safe_c): + """Randomly place mines, avoiding the safe cell and its neighbors.""" + if self.mines_placed: + raise RuntimeError("Mines have already been placed.") + if not self._in_bounds(safe_r, safe_c): + raise ValueError(f"Safe cell outside the board: {(safe_r, safe_c)}") + protected = {(safe_r, safe_c)} | set(self.neighbors(safe_r, safe_c)) + candidates = [ + (r, c) + for r in range(self.rows) + for c in range(self.cols) + if (r, c) not in protected + ] + if len(candidates) < self.mine_count: + raise ValueError("Not enough free cells to place all mines safely.") + self.mines = set(self._rng.sample(candidates, self.mine_count)) + self.mines_placed = True + return self.mines + + # ---------------------------------------------------------- actions + + def reveal(self, r, c): + """Reveal a cell; flood-clears empty areas. Returns newly revealed cells. + + Ignores flagged or already revealed cells. Revealing a mine ends + the game and exposes every mine. + """ + if self.lost or self.is_won or not self._in_bounds(r, c): + return set() + if (r, c) in self.flagged or (r, c) in self.revealed: + return set() + if not self.mines_placed: + self.place_mines(r, c) + if (r, c) in self.mines: + self.lost = True + self.exploded_at = (r, c) + self.revealed.update(self.mines) + return set(self.mines) + newly = set() + stack = [(r, c)] + while stack: + cell = stack.pop() + if cell in self.revealed or cell in self.flagged: + continue + self.revealed.add(cell) + newly.add(cell) + if self.neighbor_mine_count(*cell) == 0: + for nb in self.neighbors(*cell): + if nb not in self.revealed and nb not in self.flagged: + stack.append(nb) + return newly + + def toggle_flag(self, r, c): + """Flag/unflag a hidden cell. True=flagged, False=unflagged, None=refused.""" + if self.lost or self.is_won or not self._in_bounds(r, c): + return None + if (r, c) in self.revealed: + return None + if (r, c) in self.flagged: + self.flagged.discard((r, c)) + return False + self.flagged.add((r, c)) + return True + + # ---------------------------------------------------------- state + + @property + def flags_remaining(self): + """Mines minus flags placed (may go negative).""" + return self.mine_count - len(self.flagged) + + @property + def is_won(self): + """True once every non-mine cell is revealed.""" + return ( + self.mines_placed + and not self.lost + and len(self.revealed) == self.rows * self.cols - self.mine_count + ) + + +# ------------------------------------------------------------------ UI + +class Game: + """Real-time terminal interface for Minesweeper.""" + + def run(self): + term.hide_cursor() + try: + while True: + diff_key = self._choose_difficulty() + if diff_key is None: + return + if not self._play(DIFFICULTIES[diff_key]): + return + finally: + term.show_cursor() + + # ---------------------------------------------------------- menus + + def _choose_difficulty(self): + store = get_store() + title = [ + term.style("MINESWEEPER", term.Color.CYAN, term.Color.BOLD), + term.style(DESCRIPTION, term.Color.GRAY), + ] + options = [] + for key in DIFFICULTY_ORDER: + diff = DIFFICULTIES[key] + best = store.get_record(diff.record_key) + best_text = format_time(best) if best is not None else "--:--" + options.append( + f"{diff.name} - {diff.cols}x{diff.rows}, {diff.mines} mines" + f" (best {best_text})" + ) + choice = choose( + title, options, hotkeys=["1", "2", "3"], + hint="Arrows/W-S move - Enter select - Q/ESC back", + ) + if choice is None: + return None + return DIFFICULTY_ORDER[choice] + + def _show_help(self): + term.clear() + print() + term.print_centered([ + term.style("MINESWEEPER - HELP", term.Color.CYAN, term.Color.BOLD), + "", + ]) + lines = [ + "Objective:", + " Reveal every safe cell without detonating a mine.", + "", + "Controls:", + " Arrows / WASD move the cursor", + " Enter / Space reveal the cell under the cursor", + " F place or remove a flag", + " H show this help", + " Q / ESC return to the main menu", + "", + "Rules:", + " A number tells how many mines touch that cell.", + " Revealing an empty cell clears the whole open area.", + " The first reveal is always safe, and so are its neighbors.", + " The counter shows mines minus the flags you placed.", + " Win by revealing every cell that is not a mine.", + "", + "Quitting while a game is in progress asks for confirmation.", + ] + for line in lines: + print(term.center(line)) + print() + term.press_enter() + + # ---------------------------------------------------------- play + + def _play(self, diff): + """Play one board. Returns True to pick another, False for the menu.""" + board = Board(diff.rows, diff.cols, diff.mines) + cursor_r, cursor_c = diff.rows // 2, diff.cols // 2 + start = None # clock starts on the first reveal + message = "" + while True: + if not self._wait_for_fit(diff): + return False + seconds = int(time.monotonic() - start) if start is not None else 0 + self._render(board, diff, cursor_r, cursor_c, seconds, message) + message = "" + key = read_key() + low = key.lower() if isinstance(key, str) and len(key) == 1 else "" + if key == Key.UP or low == "w": + cursor_r = max(0, cursor_r - 1) + elif key == Key.DOWN or low == "s": + cursor_r = min(diff.rows - 1, cursor_r + 1) + elif key == Key.LEFT or low == "a": + cursor_c = max(0, cursor_c - 1) + elif key == Key.RIGHT or low == "d": + cursor_c = min(diff.cols - 1, cursor_c + 1) + elif key == Key.ENTER or low == " ": + board.reveal(cursor_r, cursor_c) + if start is None and board.mines_placed: + start = time.monotonic() + elif low == "f": + if board.toggle_flag(cursor_r, cursor_c) is None: + message = "You cannot flag that cell." + elif low == "h": + self._show_help() + elif low == "q" or key == Key.ESCAPE: + if board.lost or board.is_won or not board.revealed: + return False + if term.confirm("Return to the main menu?"): + return False + # any other key is ignored + if board.lost or board.is_won: + break + elapsed = time.monotonic() - start if start is not None else 0 + return self._finish(board, diff, elapsed) + + def _finish(self, board, diff, elapsed): + """Result screen and statistics. Returns True to play again.""" + seconds = int(elapsed) + store = get_store() + improved = False + if board.is_won: + store.record_result(KEY, "win") + improved = store.improve_record(diff.record_key, seconds, + lower_is_better=True) + else: + store.record_result(KEY, "loss") + self._render(board, diff, -1, -1, seconds, "") + print() + if board.is_won: + term.print_centered([term.style( + f"You cleared the field in {format_time(seconds)}!", + term.Color.GREEN, term.Color.BOLD)]) + if improved: + term.print_centered([term.style( + "New best time for this difficulty!", term.Color.YELLOW)]) + else: + term.print_centered([term.style( + "BOOM! You hit a mine.", term.Color.RED, term.Color.BOLD)]) + print() + return term.confirm("Play again?") + + # ---------------------------------------------------------- render + + def _board_fits(self, diff): + cols, rows = term.size() + return cols >= diff.cols * CELL_WIDTH + 2 and rows >= diff.rows + 5 + + def _wait_for_fit(self, diff): + """Block until the terminal fits the board. False means give up.""" + while not self._board_fits(diff): + term.clear() + need_cols = diff.cols * CELL_WIDTH + 2 + need_rows = diff.rows + 5 + cols, rows = term.size() + print() + term.print_centered([ + term.style("The board does not fit your terminal.", term.Color.YELLOW), + "", + f"{diff.name} needs at least {need_cols}x{need_rows};" + f" you have {cols}x{rows}.", + "", + term.style("Resize the window and press any key, or Q to go back.", + term.Color.GRAY), + ]) + key = read_key() + if key == Key.ESCAPE or (isinstance(key, str) and key.lower() == "q"): + return False + return True + + def _render(self, board, diff, cursor_r, cursor_c, seconds, message): + term.clear() + cols, _ = term.size() + header = (f"{diff.name} - Time {format_time(seconds)}" + f" - Flags left {board.flags_remaining}") + print(term.center(term.style(header, term.Color.CYAN), cols)) + print() + pad = max(0, (cols - diff.cols * CELL_WIDTH) // 2) + for r in range(diff.rows): + line = "".join( + self._cell(board, r, c, r == cursor_r and c == cursor_c) + for c in range(diff.cols) + ) + print(" " * pad + line) + print() + if message: + print(term.center(term.style(message, term.Color.YELLOW), cols)) + else: + hint = "Arrows/WASD move - Enter/Space reveal - F flag - H help - Q quit" + print(term.center(term.style(hint, term.Color.GRAY), cols)) + + def _cell(self, board, r, c, cursor): + """One 3-character-wide board cell.""" + flag_glyph = "⚑" if term.UNICODE else "F" + mine_glyph = "●" if term.UNICODE else "*" + codes = () + if (r, c) in board.revealed: + if (r, c) in board.mines: + glyph, codes = mine_glyph, (term.Color.RED,) + if board.exploded_at == (r, c): + codes = (term.Color.RED, term.Color.BOLD) + else: + count = board.neighbor_mine_count(r, c) + glyph = str(count) if count else " " + codes = (term.Color.WHITE,) if count else () + elif (r, c) in board.flagged: + glyph, codes = flag_glyph, (term.Color.YELLOW,) + else: + glyph, codes = ".", (term.Color.GRAY,) + if cursor: + codes = codes + (term.Color.BOLD,) + return term.style(f"[{glyph}]", *codes) + return term.style(f" {glyph} ", *codes)
@@ -0,0 +1,444 @@+"""Reversi (Othello) for Pablo's Terminal Games. + +Classic 8x8 Reversi: outflank your opponent's discs to flip them. +The pure rules live in Board / GameState / ComputerPlayer (no I/O); +the Game class is the terminal UI on top. See GAME_SPEC.md. +""" +from __future__ import annotations + +from core import terminal as term +from core.input import read_line +from core.menu import choose +from core.storage import get_store + +NAME = "Reversi" +KEY = "reversi" +DESCRIPTION = "Outflank and flip your opponent's discs on an 8x8 board." + +EMPTY = 0 +BLACK = 1 # shown as X / ●, moves first +WHITE = 2 # shown as O / ○ + +SIZE = 8 +DIRECTIONS = ( + (-1, -1), (-1, 0), (-1, 1), + (0, -1), (0, 1), + (1, -1), (1, 0), (1, 1), +) + +LABELS = {EMPTY: ".", BLACK: "X", WHITE: "O"} + + +def opponent(player: int) -> int: + """Return the other player's constant.""" + return WHITE if player == BLACK else BLACK + + +class Board: + """Pure 8x8 Reversi board: placement, legal moves and flipping.""" + + def __init__(self) -> None: + self.grid = [[EMPTY] * SIZE for _ in range(SIZE)] + mid = SIZE // 2 + # Standard opening: white on d4/e5, black on e4/d5 (0-indexed). + self.grid[mid - 1][mid - 1] = WHITE + self.grid[mid - 1][mid] = BLACK + self.grid[mid][mid - 1] = BLACK + self.grid[mid][mid] = WHITE + + def copy(self) -> "Board": + clone = Board.__new__(Board) + clone.grid = [row[:] for row in self.grid] + return clone + + @staticmethod + def inside(row: int, col: int) -> bool: + return 0 <= row < SIZE and 0 <= col < SIZE + + def get(self, row: int, col: int) -> int: + return self.grid[row][col] + + def flips_for(self, player: int, row: int, col: int) -> list: + """All opponent discs flipped by placing at (row, col). + + Returns an empty list when the move is not legal. + """ + if not self.inside(row, col) or self.grid[row][col] != EMPTY: + return [] + enemy = opponent(player) + flipped = [] + for d_row, d_col in DIRECTIONS: + line = [] + r, c = row + d_row, col + d_col + while self.inside(r, c) and self.grid[r][c] == enemy: + line.append((r, c)) + r += d_row + c += d_col + # The line only counts when it ends on one of our own discs. + if line and self.inside(r, c) and self.grid[r][c] == player: + flipped.extend(line) + return flipped + + def is_legal(self, player: int, row: int, col: int) -> bool: + return bool(self.flips_for(player, row, col)) + + def legal_moves(self, player: int) -> list: + """All legal (row, col) moves for player, in board order.""" + return [ + (row, col) + for row in range(SIZE) + for col in range(SIZE) + if self.flips_for(player, row, col) + ] + + def has_move(self, player: int) -> bool: + return any( + self.flips_for(player, row, col) + for row in range(SIZE) + for col in range(SIZE) + ) + + def apply(self, player: int, row: int, col: int) -> list: + """Place a disc and flip every outflanked line. + + Returns the list of flipped squares. Raises ValueError on an + illegal move. + """ + flipped = self.flips_for(player, row, col) + if not flipped: + raise ValueError(f"Illegal move at row {row}, col {col}") + self.grid[row][col] = player + for r, c in flipped: + self.grid[r][c] = player + return flipped + + def count(self) -> dict: + """Disc counts per player: {BLACK: n, WHITE: n}.""" + return { + BLACK: sum(cell == BLACK for row in self.grid for cell in row), + WHITE: sum(cell == WHITE for row in self.grid for cell in row), + } + + +class GameState: + """Turn flow for one Reversi game: passes, game over, scoring.""" + + def __init__(self) -> None: + self.board = Board() + self.current = BLACK # black always opens + + def legal_moves(self) -> list: + return self.board.legal_moves(self.current) + + def must_pass(self) -> bool: + """True when the current player has no legal move but the game + is not over.""" + return not self.board.has_move(self.current) and not self.game_over() + + def game_over(self) -> bool: + """The game ends when neither player can move.""" + return not self.board.has_move(BLACK) and not self.board.has_move(WHITE) + + def play(self, row: int, col: int) -> list: + """Play the current player's move and hand over the turn. + + Returns the flipped squares; raises ValueError on an illegal + move. The caller is responsible for pass handling: check + must_pass() and call pass_turn() when the next player is stuck. + """ + flipped = self.board.apply(self.current, row, col) + self.current = opponent(self.current) + return flipped + + def pass_turn(self) -> None: + """Skip the current player's turn (automatic pass).""" + self.current = opponent(self.current) + + def score(self) -> tuple: + """(black discs, white discs).""" + counts = self.board.count() + return counts[BLACK], counts[WHITE] + + def winner(self): + """BLACK, WHITE, or None for a draw. Only valid once game_over().""" + black, white = self.score() + if black > white: + return BLACK + if white > black: + return WHITE + return None + + +# ---------------------------------------------------------------- computer AI + +# Positional value of each square. Corners are precious, the squares +# next to a corner are dangerous while that corner is still empty, and +# edges are generally good. +_BASE_WEIGHTS = ( + (100, -20, 10, 5, 5, 10, -20, 100), + (-20, -40, -5, -5, -5, -5, -40, -20), + ( 10, -5, 5, 1, 1, 5, -5, 10), + ( 5, -5, 1, 1, 1, 1, -5, 5), + ( 5, -5, 1, 1, 1, 1, -5, 5), + ( 10, -5, 5, 1, 1, 5, -5, 10), + (-20, -40, -5, -5, -5, -5, -40, -20), + (100, -20, 10, 5, 5, 10, -20, 100), +) + +CORNERS = ((0, 0), (0, SIZE - 1), (SIZE - 1, 0), (SIZE - 1, SIZE - 1)) + +# Squares adjacent to each corner (the two "C" squares and the "X" square). +_CORNER_NEIGHBORS = { + (0, 0): ((0, 1), (1, 0), (1, 1)), + (0, SIZE - 1): ((0, SIZE - 2), (1, SIZE - 1), (1, SIZE - 2)), + (SIZE - 1, 0): ((SIZE - 2, 0), (SIZE - 1, 1), (SIZE - 2, 1)), + (SIZE - 1, SIZE - 1): ((SIZE - 2, SIZE - 1), (SIZE - 1, SIZE - 2), + (SIZE - 2, SIZE - 2)), +} + + +class ComputerPlayer: + """Heuristic AI: positional value plus immediate flip count.""" + + def __init__(self, player: int) -> None: + self.player = player + + def _weight(self, board: Board, row: int, col: int) -> int: + weight = _BASE_WEIGHTS[row][col] + # Squares next to an empty corner are a liability; once the + # corner is taken they lose the penalty. + for corner, neighbors in _CORNER_NEIGHBORS.items(): + if (row, col) in neighbors and board.get(*corner) != EMPTY: + weight = max(weight, 2) + return weight + + def evaluate(self, board: Board, row: int, col: int) -> float: + """Score a legal move: position value plus discs flipped.""" + return self._weight(board, row, col) + len(board.flips_for(self.player, row, col)) + + def choose_move(self, board: Board): + """Best legal (row, col) move, or None when there is none.""" + moves = board.legal_moves(self.player) + if not moves: + return None + return max(moves, key=lambda mv: self.evaluate(board, *mv)) + + +# ---------------------------------------------------------------- UI helpers + +def parse_coordinate(text: str): + """Parse 'd3'-style input into (row, col), or None if malformed.""" + cleaned = text.strip().lower().replace(" ", "") + if len(cleaned) != 2: + return None + letter, digit = cleaned + col = ord(letter) - ord("a") + row = ord(digit) - ord("1") + if 0 <= row < SIZE and 0 <= col < SIZE and letter.isalpha() and digit.isdigit(): + return (row, col) + return None + + +def coordinate_name(row: int, col: int) -> str: + """Human-readable name for a square, e.g. (2, 3) -> 'd3'.""" + return f"{chr(ord('a') + col)}{row + 1}" + + +class Game: + """Interactive Reversi session: mode selection, play loop, replay.""" + + def __init__(self) -> None: + self.store = get_store() + + def run(self) -> None: + term.hide_cursor() + try: + if not self._choose_mode(): + return + while True: + self._play_once() + if not term.confirm("Play again?"): + return + finally: + term.show_cursor() + + # ------------------------------------------------------------ setup + + def _choose_mode(self) -> bool: + """Pick PvC or PvP. False means back to the menu.""" + choice = choose( + [term.style(NAME, term.Color.CYAN, term.Color.BOLD), "", DESCRIPTION], + ["Player vs Computer", "Player vs Player"], + hotkeys=["1", "2"], + hint="Enter select - H help - Q/ESC back to menu", + extra_keys="h", + ) + if choice == "h": + self._show_help() + return self._choose_mode() + if choice is None: + return False + self.vs_computer = choice == 0 + return True + + # ------------------------------------------------------------ one game + + def _play_once(self) -> None: + state = GameState() + computer = ComputerPlayer(WHITE) if self.vs_computer else None + while not state.game_over(): + self._draw(state) + if state.must_pass(): + name = self._player_name(state.current) + term.status(f"{name} has no legal move and must pass.", "warn") + term.press_enter() + state.pass_turn() + continue + if computer is not None and state.current == computer.player: + move = computer.choose_move(state.board) + state.play(*move) + term.status( + f"Computer plays {coordinate_name(*move)}.", "dim") + term.press_enter() + continue + if not self._human_turn(state): + return # player quit to the menu + self._finish(state) + + def _human_turn(self, state: GameState) -> bool: + """Prompt the human until a valid move is played. + + Returns False when the player chooses to leave the game. + """ + while True: + prompt = (f"{self._player_name(state.current)}'s move " + "(e.g. d3, h help, q quit): ") + answer = read_line(prompt) + if answer is None: + return False + text = answer.strip().lower() + if text == "q": + if term.confirm("Return to the main menu?"): + return False + self._draw(state) + continue + if text == "h": + self._show_help() + self._draw(state) + continue + move = parse_coordinate(text) + if move is None: + term.status("Invalid input. Use coordinates like d3.", "error") + continue + if not state.board.is_legal(state.current, *move): + term.status("Invalid move. You must outflank at least " + "one opponent disc.", "error") + continue + state.play(*move) + return True + + def _finish(self, state: GameState) -> None: + self._draw(state, show_moves=False) + black, white = state.score() + winner = state.winner() + print(term.center(f"Final score - X: {black} O: {white}")) + print() + if self.vs_computer: + if winner == BLACK: + term.status("You win! The board is yours.", "ok") + self.store.record_result(KEY, "win") + elif winner == WHITE: + term.status("The computer wins this one.", "error") + self.store.record_result(KEY, "loss") + else: + term.status("A perfect tie. Well played.", "warn") + self.store.record_result(KEY, "draw") + else: + if winner is None: + term.status("A perfect tie. Well played.", "warn") + self.store.record_result(KEY, "draw") + else: + term.status(f"{self._player_name(winner)} wins!", "ok") + self.store.record_result(KEY, "win") + print() + + # ------------------------------------------------------------ rendering + + def _player_name(self, player: int) -> str: + symbol = self._disc(player) + if self.vs_computer: + side = "You" if player == BLACK else "Computer" + return f"{side} ({symbol})" + return f"Player {symbol}" + + @staticmethod + def _disc(player: int) -> str: + if term.UNICODE: + return {BLACK: "●", WHITE: "○"}[player] + return LABELS[player] + + def _draw(self, state: GameState, show_moves: bool = True) -> None: + term.clear() + print() + print(term.center(term.style(NAME, term.Color.CYAN, term.Color.BOLD))) + black, white = state.score() + print(term.center( + f"{self._disc(BLACK)} {black:2d} - {white:2d} {self._disc(WHITE)}")) + print() + + hint = set(state.legal_moves()) if show_moves else set() + dot = "·" if term.UNICODE else "*" + header = " " + " ".join(chr(ord("a") + c) for c in range(SIZE)) + lines = [term.style(header, term.Color.GRAY)] + for row in range(SIZE): + cells = [] + for col in range(SIZE): + value = state.board.get(row, col) + if value == EMPTY: + mark = term.style(dot, term.Color.GREEN) if (row, col) in hint \ + else term.style(".", term.Color.GRAY) + else: + color = term.Color.WHITE if value == BLACK else term.Color.CYAN + mark = term.style(self._disc(value), color, term.Color.BOLD) + cells.append(mark) + number = term.style(f"{row + 1:2d} ", term.Color.GRAY) + lines.append(f" {number} " + " ".join(cells)) + width = max(term.visible_len(line) for line in lines) + pad = max(0, (term.size()[0] - width) // 2) + for line in lines: + print(" " * pad + line) + print() + if show_moves and hint: + shown = ", ".join(coordinate_name(r, c) for r, c in sorted(hint)) + print(term.center(term.style(f"Legal moves: {shown}", term.Color.GRAY))) + print() + + def _show_help(self) -> None: + term.clear() + print() + term.print_centered([ + term.style(f"{NAME} - HELP", term.Color.CYAN, term.Color.BOLD), ""]) + lines = [ + "OBJECTIVE", + " Finish the game with more discs of your color on", + " the 8x8 board than your opponent.", + "", + "CONTROLS", + " Type a coordinate such as d3 and press Enter.", + " h show this help q return to the main menu", + "", + "RULES", + " - Black (X) moves first; players alternate turns.", + " - A move is legal only when it traps at least one", + " opponent disc between the new disc and one of", + " yours, in any of the 8 directions.", + " - Every trapped line is flipped to your color.", + " - Legal moves are marked with dots on the board.", + " - If you have no legal move, your turn is passed.", + " - The game ends when neither player can move;", + " the higher disc count wins.", + ] + for line in lines: + print(term.center(line)) + print() + term.press_enter()
@@ -0,0 +1,314 @@+"""Tic-Tac-Toe - classic 3x3 game for Pablo's Terminal Games. + +Pure game logic lives in ``Board``, ``parse_coordinate`` and +``choose_computer_move`` (no printing, no input) so unit tests can +drive them headless. ``Game`` is the interactive UI layer on top. +""" +from __future__ import annotations + +import random + +from core import terminal as term +from core.input import read_line +from core.menu import choose +from core.storage import get_store + +NAME = "Tic-Tac-Toe" +KEY = "tic_tac_toe" +DESCRIPTION = "The classic 3x3 game of Xs and Os." + +EMPTY = " " +MARK_X = "X" +MARK_O = "O" + +COLUMNS = "ABC" # column labels, index 0 -> "A" +SIZE = 3 + +# All eight winning lines as (row, col) triples: 3 rows, 3 columns, +# 2 diagonals. +WIN_LINES = ( + ((0, 0), (0, 1), (0, 2)), + ((1, 0), (1, 1), (1, 2)), + ((2, 0), (2, 1), (2, 2)), + ((0, 0), (1, 0), (2, 0)), + ((0, 1), (1, 1), (2, 1)), + ((0, 2), (1, 2), (2, 2)), + ((0, 0), (1, 1), (2, 2)), + ((0, 2), (1, 1), (2, 0)), +) + + +def parse_coordinate(text): + """Parse 'b2'-style input (case-insensitive) into (row, col). + + Returns None when the text is not a valid on-board coordinate. + """ + if not text: + return None + text = text.strip().lower() + if len(text) != 2: + return None + col_char, row_char = text[0], text[1] + if col_char not in "abc" or row_char not in "123": + return None + return int(row_char) - 1, ord(col_char) - ord("a") + + +def format_coordinate(row, col): + """Inverse of parse_coordinate: (1, 1) -> 'b2'.""" + return f"{COLUMNS[col].lower()}{row + 1}" + + +class Board: + """Pure 3x3 board state. No printing, no input.""" + + def __init__(self): + self.cells = [[EMPTY] * SIZE for _ in range(SIZE)] + + def get(self, row, col): + return self.cells[row][col] + + def is_empty(self, row, col): + return self.cells[row][col] == EMPTY + + def place(self, mark, row, col): + """Place mark at (row, col). Returns False if already occupied.""" + if mark not in (MARK_X, MARK_O) or not self.is_empty(row, col): + return False + self.cells[row][col] = mark + return True + + def empty_cells(self): + """List of (row, col) pairs that are still free.""" + return [(r, c) for r in range(SIZE) for c in range(SIZE) + if self.cells[r][c] == EMPTY] + + def is_full(self): + return not self.empty_cells() + + def winning_line(self): + """Return the winning (line, mark) or None. + + ``line`` is a tuple of three (row, col) pairs. The game is a + draw when the board is full and this returns None. + """ + for line in WIN_LINES: + marks = [self.cells[r][c] for r, c in line] + if marks[0] != EMPTY and marks.count(marks[0]) == SIZE: + return line, marks[0] + return None + + def winner(self): + """Return MARK_X or MARK_O if someone won, else None.""" + result = self.winning_line() + return result[1] if result else None + + def is_game_over(self): + return self.winner() is not None or self.is_full() + + +def _find_line_move(board, mark): + """Find a cell that completes three-in-a-row for mark, or None.""" + for line in WIN_LINES: + marks = [board.get(r, c) for r, c in line] + if marks.count(mark) == 2 and marks.count(EMPTY) == 1: + return line[marks.index(EMPTY)] + return None + + +def choose_computer_move(board, mark, rng=None): + """Pick a reasonable move for the computer. + + Priority: win now, block the opponent's win, take the center, + take a corner, otherwise a random free cell. Returns (row, col) + or None when the board is full. + """ + rng = rng or random + free = board.empty_cells() + if not free: + return None + opponent = MARK_O if mark == MARK_X else MARK_X + + move = _find_line_move(board, mark) + if move is not None: + return move + move = _find_line_move(board, opponent) + if move is not None: + return move + if board.is_empty(1, 1): + return (1, 1) + corners = [(r, c) for r, c in free if r in (0, 2) and c in (0, 2)] + if corners: + return rng.choice(corners) + return rng.choice(free) + + +class Game: + """Interactive UI layer: menus, rendering, prompts, statistics.""" + + def run(self): + while True: + mode = self._choose_mode() + if mode is None: + return + self._play_match(mode) + if not term.confirm("Play again?"): + return + + # ------------------------------------------------------------ menus + + def _choose_mode(self): + title = term.style(NAME, term.Color.CYAN, term.Color.BOLD) + choice = choose( + [title, "", DESCRIPTION], + ["Player vs Computer", "Player vs Player"], + hotkeys=["1", "2"], + extra_keys="h", + hint="H help - Q/ESC return to menu", + ) + if choice == "h": + self._show_help() + return self._choose_mode() + return choice # 0 (vs computer), 1 (vs player) or None (quit) + + def _show_help(self): + term.clear() + print() + lines = [ + term.style(f"HOW TO PLAY - {NAME}", term.Color.CYAN, term.Color.BOLD), + "", + term.style("Objective", term.Color.CYAN), + "Be the first to place three of your marks (X or O) in a", + "row - horizontally, vertically or diagonally. Fill the", + "board with no winner and the game is a draw.", + "", + term.style("Controls", term.Color.CYAN), + "Type a coordinate such as B2 and press Enter to play.", + "Columns are the letters A-C, rows are the numbers 1-3.", + " H show this help screen", + " Q return to the main menu", + "", + term.style("Rules", term.Color.CYAN), + "X always moves first. Marks cannot be moved or removed,", + "and you cannot play on an occupied square.", + ] + for line in term.box(lines, pad=2): + print(term.center(line)) + print() + term.press_enter() + + # ------------------------------------------------------------ match + + def _play_match(self, mode): + board = Board() + current = MARK_X + while True: + if mode == 0 and current == MARK_O: + self._render(board, "The computer is thinking...") + move = choose_computer_move(board, MARK_O) + if move is None: + break # full board, draw + board.place(MARK_O, *move) + else: + label = self._player_label(mode, current) + action = self._human_turn(board, label) + if action == "quit": + if term.confirm("Return to the main menu?"): + return + continue + if action == "help": + self._show_help() + continue + board.place(current, *action) + if board.is_game_over(): + break + current = MARK_O if current == MARK_X else MARK_X + + self._render(board, "") + self._announce_result(board, mode) + + def _player_label(self, mode, mark): + if mode == 0: + return f"Your move ({mark})" + return f"Player {mark}, your move" + + def _human_turn(self, board, label): + """Ask for a move. Returns (row, col), 'help' or 'quit'.""" + while True: + self._render(board, "") + print(term.center(label)) + answer = read_line(term.center("Move (e.g. b2, h help, q quit): ")) + if answer is None: + return "quit" + answer = answer.strip().lower() + if answer == "q": + return "quit" + if answer == "h": + return "help" + coord = parse_coordinate(answer) + if coord is None: + self._render(board, "") + term.status("Invalid move. Use a coordinate like b2.", "warn") + term.press_enter() + continue + if not board.is_empty(*coord): + self._render(board, "") + term.status("That square is already occupied.", "warn") + term.press_enter() + continue + return coord + + # ------------------------------------------------------------ render + + def _render(self, board, message): + term.clear() + print() + term.print_centered([term.style(NAME, term.Color.CYAN, term.Color.BOLD), ""]) + for line in self._grid_lines(board): + print(term.center(line)) + if message: + print() + print(term.center(term.style(message, term.Color.GRAY))) + + def _grid_lines(self, board): + # Cells are 3 characters wide so rows look like " X │ │ O " + # and separators like "───┼───┼───" (ASCII fallbacks apply). + v = term.BOX["v"] + x = term.BOX["x"] + h = term.BOX["h"] + lines = [" " + " ".join(COLUMNS)] + for row in range(SIZE): + cells = [self._cell_text(board, row, col) for col in range(SIZE)] + lines.append(f" {row + 1} " + v.join(cells)) + if row < SIZE - 1: + lines.append(" " + x.join([h * 3] * SIZE)) + return lines + + def _cell_text(self, board, row, col): + mark = board.get(row, col) + if mark == MARK_X: + return " " + term.style(MARK_X, term.Color.CYAN, term.Color.BOLD) + " " + if mark == MARK_O: + return " " + term.style(MARK_O, term.Color.YELLOW, term.Color.BOLD) + " " + return " " * 3 + + # ------------------------------------------------------------ result + + def _announce_result(self, board, mode): + winner = board.winner() + store = get_store() + print() + if winner is None: + store.record_result(KEY, "draw") + term.print_centered([term.style("It's a draw!", term.Color.YELLOW)]) + elif mode == 0: + if winner == MARK_X: + store.record_result(KEY, "win") + term.print_centered([term.style("You win! Congratulations!", term.Color.GREEN)]) + else: + store.record_result(KEY, "loss") + term.print_centered([term.style("The computer wins. Try again!", term.Color.RED)]) + else: + store.record_result(KEY, "win") + term.print_centered([term.style(f"Player {winner} wins!", term.Color.GREEN)]) + print()
A
main.py
@@ -0,0 +1,223 @@+#!/usr/bin/env python3 +"""PABLO'S TERMINAL GAMES - main entry point. + +Run with: python main.py +""" +from __future__ import annotations + +import importlib +import sys + +from core import APP_NAME, AUTHOR, EMAIL, YEAR, __version__ +from core import terminal as term +from core.input import read_line +from core.menu import choose +from core.storage import get_store +from core.utils import format_time + +GAME_REGISTRY = [ + "games.tic_tac_toe", + "games.connect_four", + "games.checkers", + "games.reversi", + "games.naval_battle", + "games.gomoku", + "games.mancala", + "games.minesweeper", + "games.game_2048", + "games.fifteen", +] + +HOTKEYS = [str(i) for i in range(1, 10)] + ["0"] # "0" selects game 10 + + +def banner_lines() -> list[str]: + title = term.style(APP_NAME, term.Color.CYAN, term.Color.BOLD) + version = term.style(f"VERSION {__version__}", term.Color.GRAY) + inner = max(term.visible_len(title), term.visible_len(version)) + 10 + b = term.BOX_DOUBLE + lines = [b["tl"] + b["h"] * inner + b["tr"]] + for text in ("", title, "", version, ""): + pad = (inner - term.visible_len(text)) // 2 + lines.append(b["v"] + " " * pad + text + " " * (inner - pad - term.visible_len(text)) + b["v"]) + lines.append(b["bl"] + b["h"] * inner + b["br"]) + return lines + + +def ensure_size() -> bool: + """Warn when the terminal is too small. False means the user quit.""" + while term.too_small(): + term.clear() + cols, rows = term.size() + print() + term.print_centered([ + term.style("Your terminal is too small.", term.Color.YELLOW), + "", + f"Current size: {cols}x{rows}.", + f"Please resize it to at least {term.MIN_COLS}x{term.MIN_ROWS}.", + "", + term.style("Press Enter to check again, or Q to quit.", term.Color.GRAY), + ]) + answer = read_line() + if answer is None: + return False + if answer.strip().lower() == "q": + return False + return True + + +def launch_game(index: int) -> None: + module_name = GAME_REGISTRY[index] + display_name = module_name + try: + module = importlib.import_module(module_name) + display_name = module.NAME + module.Game().run() + except KeyboardInterrupt: + pass # Ctrl-C inside a game: just return to the menu + except Exception as exc: # never dump a traceback on the player + term.clear() + print() + term.status(f"Something went wrong in {display_name}: {exc}", "error") + term.status("The game room is still running. Returning to the menu.", "dim") + term.press_enter() + finally: + term.cleanup() + + +def show_help() -> None: + term.clear() + print() + term.print_centered([term.style("HELP", term.Color.CYAN, term.Color.BOLD), ""]) + lines = [ + "Choose a game with the arrow keys (or W/S) and Enter,", + "or press its number. Press H for help, S for statistics,", + "A for about, and Q or ESC to quit.", + "", + "Inside every game:", + " H show the game's rules and controls", + " Q/ESC return to this menu", + "", + "Board games accept coordinates such as B2 or 1-7,", + "depending on the game. Real-time games use the arrow", + "keys and WASD. Every game explains its own controls", + "on its help screen.", + ] + for line in lines: + print(term.center(line)) + print() + term.print_centered([term.style("The games:", term.Color.CYAN)]) + print() + for module_name in GAME_REGISTRY: + try: + module = importlib.import_module(module_name) + display_name = module.NAME + desc = module.DESCRIPTION + except Exception: + display_name, desc = module_name, "" + print(term.center(f"{display_name} - {desc}")) + print() + term.press_enter() + + +def show_stats() -> None: + store = get_store() + term.clear() + print() + term.print_centered([term.style("STATISTICS", term.Color.CYAN, term.Color.BOLD), ""]) + header = f"{'GAME':<16}{'PLAYED':>8}{'WON':>6}{'LOST':>6}{'DRAWN':>7}" + width = term.visible_len(header) + pad = max(0, (term.size()[0] - width) // 2) + print(" " * pad + term.style(header, term.Color.GRAY)) + for module_name in GAME_REGISTRY: + module = importlib.import_module(module_name) + stats = store.game_stats(module.KEY) + row = (f"{module.NAME:<16}{stats.get('played', 0):>8}{stats.get('won', 0):>6}" + f"{stats.get('lost', 0):>6}{stats.get('drawn', 0):>7}") + print(" " * pad + row) + print() + records = _record_lines(store) + if records: + term.print_centered([term.style("RECORDS", term.Color.CYAN)]) + print() + for line in records: + print(term.center(line)) + print() + term.press_enter() + + +def _record_lines(store) -> list[str]: + lines = [] + high = store.get_record("2048_high_score") + if high: + lines.append(f"2048 high score: {high}") + tile = store.get_record("2048_best_tile") + if tile: + lines.append(f"2048 best tile: {tile}") + for diff in ("easy", "medium", "hard"): + best = store.get_record(f"minesweeper_best_time_{diff}") + if best is not None: + lines.append(f"Minesweeper best time ({diff}): {format_time(best)}") + moves = store.get_record("fifteen_best_moves") + if moves is not None: + lines.append(f"Fifteen Puzzle best: {moves} moves") + best_time = store.get_record("fifteen_best_time") + if best_time is not None: + lines.append(f"Fifteen Puzzle best time: {format_time(best_time)}") + return lines + + +def show_about() -> None: + term.clear() + print() + lines = [ + "", + term.style(APP_NAME, term.Color.CYAN, term.Color.BOLD), + "", + f"Version {__version__}", + "", + f"Created by {AUTHOR}", + YEAR, + EMAIL, + "", + "A collection of small board, strategy and puzzle games", + "designed to be played entirely inside the terminal.", + "", + term.style("Made for people who still enjoy the command line.", term.Color.GRAY), + "", + ] + for line in term.box(lines, pad=3, double=False): + print(term.center(line)) + print() + term.press_enter() + + +def main() -> int: + if not ensure_size(): + return 0 + options = [importlib.import_module(m).NAME for m in GAME_REGISTRY] + hint = "Arrows/W-S move - Enter select - H help - S stats - A about - Q quit" + while True: + choice = choose(banner_lines(), options, hotkeys=HOTKEYS, + extra_keys="hsa", hint=hint) + if choice is None: + break + if choice == "h": + show_help() + elif choice == "s": + show_stats() + elif choice == "a": + show_about() + elif isinstance(choice, int): + launch_game(choice) + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except KeyboardInterrupt: + pass + finally: + term.cleanup() + print()
A
play.bat
@@ -0,0 +1,4 @@+@echo off +rem PABLO'S TERMINAL GAMES - Windows launcher +cd /d "%~dp0" +python main.py %*
A
play.sh
@@ -0,0 +1,4 @@+#!/bin/sh +# PABLO'S TERMINAL GAMES - Linux launcher +cd "$(dirname "$0")" +exec python3 main.py "$@"
@@ -0,0 +1,262 @@+"""Headless tests for games.game_2048 (pure Board logic only).""" +from __future__ import annotations + +import random +import unittest + +from games.game_2048 import Board + + +def make_board(rows, seed=1234, spawn=False): + """Build a Board with a fixed grid and a seeded RNG. + + With spawn=False the random tile spawn is disabled so moves can + be asserted against an exact grid. + """ + board = Board(grid=rows, rng=random.Random(seed)) + if not spawn: + board.spawn_tile = lambda: None + return board + + +def tile_count(board): + return sum(1 for row in board.grid for value in row if value) + + +class InitialBoardTest(unittest.TestCase): + def test_new_board_starts_with_two_tiles(self): + board = Board(rng=random.Random(7)) + self.assertEqual(tile_count(board), 2) + values = [v for row in board.grid for v in row if v] + self.assertTrue(set(values) <= {2, 4}) + self.assertEqual(board.score, 0) + + def test_spawn_values_are_two_or_four(self): + rng = random.Random(42) + values = [] + for _ in range(50): + board = Board(rng=rng) # spawns two tiles per board + values.extend(v for row in board.grid for v in row if v) + self.assertTrue(set(values) <= {2, 4}) + self.assertIn(2, values) + self.assertIn(4, values) # the 10% case shows up over 100 spawns + + +class MovementTest(unittest.TestCase): + def test_slide_left(self): + board = make_board([ + [0, 2, 0, 4], + [2, 0, 2, 0], + [0, 0, 0, 0], + [8, 8, 4, 4], + ]) + self.assertTrue(board.move("left")) + self.assertEqual(board.grid, [ + [2, 4, 0, 0], + [4, 0, 0, 0], + [0, 0, 0, 0], + [16, 8, 0, 0], + ]) + self.assertEqual(board.score, 4 + 16 + 8) + + def test_slide_right(self): + board = make_board([ + [0, 2, 0, 4], + [2, 0, 2, 0], + [0, 0, 0, 0], + [8, 8, 4, 4], + ]) + self.assertTrue(board.move("right")) + self.assertEqual(board.grid, [ + [0, 0, 2, 4], + [0, 0, 0, 4], + [0, 0, 0, 0], + [0, 0, 16, 8], + ]) + self.assertEqual(board.score, 4 + 16 + 8) + + def test_slide_up(self): + board = make_board([ + [0, 0, 0, 8], + [2, 0, 0, 8], + [0, 0, 4, 4], + [2, 4, 4, 0], + ]) + self.assertTrue(board.move("up")) + self.assertEqual(board.grid, [ + [4, 4, 8, 16], + [0, 0, 0, 4], + [0, 0, 0, 0], + [0, 0, 0, 0], + ]) + self.assertEqual(board.score, 4 + 8 + 16) # column 1 slides, no merge + + def test_slide_down(self): + board = make_board([ + [0, 0, 0, 8], + [2, 0, 0, 8], + [0, 0, 4, 4], + [2, 4, 4, 0], + ]) + self.assertTrue(board.move("down")) + self.assertEqual(board.grid, [ + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 16], + [4, 4, 8, 4], + ]) + self.assertEqual(board.score, 4 + 8 + 16) # column 1 slides, no merge + + def test_unknown_direction_raises(self): + board = make_board([[0] * 4 for _ in range(4)]) + with self.assertRaises(ValueError): + board.move("diagonal") + + +class MergeTest(unittest.TestCase): + def test_merge_line_pairs(self): + self.assertEqual(Board.merge_line([2, 2, 4, 4]), ([4, 8, 0, 0], 12)) + self.assertEqual(Board.merge_line([4, 4, 8, 8]), ([8, 16, 0, 0], 24)) + self.assertEqual(Board.merge_line([0, 0, 0, 0]), ([0, 0, 0, 0], 0)) + + def test_no_double_merge_in_one_move(self): + # A tile created by a merge must not merge again in the same move. + self.assertEqual(Board.merge_line([2, 2, 2, 2]), ([4, 4, 0, 0], 8)) + self.assertEqual(Board.merge_line([2, 2, 2, 0]), ([4, 2, 0, 0], 4)) + self.assertEqual(Board.merge_line([4, 4, 4, 4]), ([8, 8, 0, 0], 16)) + + def test_merge_left_scores_once(self): + board = make_board([ + [2, 2, 2, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + ]) + self.assertTrue(board.move("left")) + self.assertEqual(board.grid[0], [4, 2, 0, 0]) + self.assertEqual(board.score, 4) # not 8: the new 4 cannot re-merge + + def test_merge_direction_changes_pairing(self): + board = make_board([ + [2, 2, 2, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + ]) + board.move("right") + self.assertEqual(board.grid[0], [0, 0, 2, 4]) + self.assertEqual(board.score, 4) + + def test_score_accumulates_across_moves(self): + board = make_board([ + [2, 2, 0, 0], + [2, 2, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + ]) + board.move("left") # two rows of 2+2 -> 4 + 4 = 8 + board.move("up") # the two new 4s merge -> +8 + self.assertEqual(board.score, 8 + 8) + + +class SpawnTest(unittest.TestCase): + def test_valid_move_spawns_exactly_one_tile(self): + board = make_board([ + [0, 2, 4, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + ], spawn=True) + self.assertEqual(tile_count(board), 2) + self.assertTrue(board.move("left")) # no merge, just a slide + self.assertEqual(tile_count(board), 3) + values = [v for row in board.grid for v in row if v] + self.assertTrue(set(values) <= {2, 4}) + + def test_invalid_move_spawns_nothing(self): + board = make_board([ + [2, 0, 0, 0], + [4, 0, 0, 0], + [8, 0, 0, 0], + [16, 0, 0, 0], + ], spawn=True) + before = [row[:] for row in board.grid] + self.assertFalse(board.move("left")) # already packed left + self.assertEqual(board.grid, before) + self.assertEqual(board.score, 0) + self.assertEqual(tile_count(board), 4) + + def test_spawn_on_full_board_returns_none(self): + board = make_board([ + [2, 4, 2, 4], + [4, 2, 4, 2], + [2, 4, 2, 4], + [4, 2, 4, 2], + ], spawn=True) + self.assertIsNone(board.spawn_tile()) + + +class GameOverTest(unittest.TestCase): + def test_full_board_without_merges_is_game_over(self): + board = make_board([ + [2, 4, 2, 4], + [4, 2, 4, 2], + [2, 4, 2, 4], + [4, 2, 4, 2], + ]) + self.assertTrue(board.is_game_over()) + self.assertFalse(board.has_moves()) + + def test_empty_cell_means_not_game_over(self): + board = make_board([ + [2, 4, 2, 4], + [4, 2, 4, 2], + [2, 4, 0, 4], + [4, 2, 4, 2], + ]) + self.assertFalse(board.is_game_over()) + + def test_available_merge_means_not_game_over(self): + board = make_board([ + [2, 4, 2, 4], + [4, 2, 4, 2], + [2, 4, 2, 4], + [4, 2, 2, 2], + ]) + self.assertFalse(board.is_game_over()) + + def test_vertical_merge_means_not_game_over(self): + board = make_board([ + [2, 4, 2, 4], + [2, 2, 4, 2], + [4, 4, 2, 4], + [8, 2, 4, 2], + ]) + self.assertFalse(board.is_game_over()) + + +class HighestTileTest(unittest.TestCase): + def test_highest_tile(self): + board = make_board([ + [2, 0, 64, 0], + [0, 128, 0, 0], + [0, 0, 0, 0], + [4, 0, 0, 32], + ]) + self.assertEqual(board.highest_tile(), 128) + self.assertFalse(board.has_won()) + + def test_merging_to_target_wins(self): + board = make_board([ + [1024, 1024, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + ]) + board.move("left") + self.assertEqual(board.highest_tile(), 2048) + self.assertTrue(board.has_won()) + + +if __name__ == "__main__": + unittest.main()
@@ -0,0 +1,258 @@+"""Headless unit tests for games.checkers pure logic.""" +import random +import unittest + +from games.checkers import ( + BLACK, WHITE, CheckersState, Move, choose_computer_chain, + coords_to_square, opponent, parse_move, square_to_coords, +) + + +def make_state(pieces, turn=WHITE): + """Build a state from {'b6': 'w', ...} with no standard setup.""" + state = CheckersState(setup=False) + for square, piece in pieces.items(): + row, col = square_to_coords(square) + state.set_piece(row, col, piece) + state.turn = turn + return state + + +def move(text): + src, dst = parse_move(text) + return Move(src, dst) + + +def notations(moves): + return {m.notation() for m in moves} + + +class CoordinateTests(unittest.TestCase): + def test_square_to_coords(self): + self.assertEqual(square_to_coords("a1"), (0, 0)) + self.assertEqual(square_to_coords("h8"), (7, 7)) + self.assertEqual(square_to_coords("B6"), (5, 1)) + + def test_square_to_coords_invalid(self): + for bad in ("", "i1", "a9", "a", "a10", None): + self.assertIsNone(square_to_coords(bad)) + + def test_coords_to_square_roundtrip(self): + self.assertEqual(coords_to_square(5, 1), "b6") + self.assertEqual(square_to_coords(coords_to_square(2, 3)), (2, 3)) + + def test_parse_move_formats(self): + self.assertEqual(parse_move("b6-a5"), ((5, 1), (4, 0))) + self.assertEqual(parse_move("b6 a5"), ((5, 1), (4, 0))) + self.assertEqual(parse_move("b6a5"), ((5, 1), (4, 0))) + self.assertEqual(parse_move("B6-A5"), ((5, 1), (4, 0))) + + def test_parse_move_invalid(self): + for bad in ("", "b6", "b6-i5", "b9-a5", "hello", None): + self.assertIsNone(parse_move(bad)) + + +class InitialPositionTests(unittest.TestCase): + def test_setup(self): + state = CheckersState() + self.assertEqual(len(state.pieces(WHITE)), 12) + self.assertEqual(len(state.pieces(BLACK)), 12) + self.assertEqual(state.turn, WHITE) + self.assertIsNone(state.must_continue) + + def test_opening_moves(self): + # Standard English draughts opening: 7 legal moves. + state = CheckersState() + self.assertEqual(len(state.legal_moves()), 7) + self.assertTrue(all(not m.is_capture for m in state.legal_moves())) + + def test_apply_simple_move_switches_turn(self): + state = CheckersState() + state.apply(move("b6-a5")) + self.assertEqual(state.piece_at(4, 0), WHITE) + self.assertEqual(state.turn, BLACK) + + +class MoveGenerationTests(unittest.TestCase): + def test_man_moves_forward_only(self): + state = make_state({"d4": "w"}) + self.assertEqual(notations(state.legal_moves()), + {"d4-c3", "d4-e3"}) + black = make_state({"d4": "b"}, turn=BLACK) + self.assertEqual(notations(black.legal_moves()), + {"d4-c5", "d4-e5"}) + + def test_king_moves_both_directions(self): + state = make_state({"d4": "W"}) + self.assertEqual(notations(state.legal_moves()), + {"d4-c3", "d4-e3", "d4-c5", "d4-e5"}) + + def test_blocked_squares_are_not_generated(self): + # d4 is boxed in by its own pieces: it has no move at all. + state = make_state({"d4": "w", "c3": "w", "e3": "w"}) + d4 = square_to_coords("d4") + self.assertFalse(any(m.src == d4 for m in state.legal_moves())) + + def test_board_edges(self): + state = make_state({"a1": "W"}) + self.assertEqual(notations(state.legal_moves()), {"a1-b2"}) + + +class ForcedCaptureTests(unittest.TestCase): + def test_capture_is_mandatory(self): + state = make_state({"d4": "w", "e3": "b", "a1": "w"}) + legal = state.legal_moves() + self.assertTrue(legal) + self.assertTrue(all(m.is_capture for m in legal)) + + def test_all_available_captures_offered(self): + state = make_state({"d4": "w", "c3": "b", "e3": "b"}) + self.assertEqual(notations(state.legal_moves()), + {"d4xb2", "d4xf2"}) + + def test_king_can_capture_backwards(self): + state = make_state({"d4": "W", "e5": "b"}) + self.assertEqual(notations(state.legal_moves()), {"d4xf6"}) + + def test_cannot_jump_own_piece(self): + # e3 is white: no jump over it, but c3 is a normal step. + state = make_state({"d4": "w", "e3": "w"}) + d4 = square_to_coords("d4") + from_d4 = {m.notation() for m in state.legal_moves() if m.src == d4} + self.assertEqual(from_d4, {"d4-c3"}) + + +class CaptureExecutionTests(unittest.TestCase): + def test_jump_removes_captured_piece(self): + state = make_state({"d4": "w", "e3": "b"}) + state.apply(move("d4-f2")) + self.assertEqual(state.piece_at(1, 5), WHITE) # landed on f2 + self.assertEqual(state.piece_at(2, 4), " ") # e3 removed + self.assertEqual(state.piece_at(3, 3), " ") # d4 empty + self.assertEqual(len(state.pieces(BLACK)), 0) + + def test_illegal_move_raises(self): + state = make_state({"d4": "w", "e3": "b"}) + with self.assertRaises(ValueError): + state.apply(move("d4-c3")) # step while a capture exists + + def test_wrong_color_raises(self): + state = make_state({"d4": "b"}) # black piece, white to move + with self.assertRaises(ValueError): + state.apply(move("d4-c5")) + + +class PromotionTests(unittest.TestCase): + def test_white_promotes_on_last_row(self): + state = make_state({"b2": "w"}) + state.apply(move("b2-a1")) + self.assertEqual(state.piece_at(0, 0), "W") + self.assertEqual(state.turn, BLACK) + + def test_black_promotes_on_last_row(self): + state = make_state({"g7": "b"}, turn=BLACK) + state.apply(move("g7-h8")) + self.assertEqual(state.piece_at(7, 7), "B") + + def test_promotion_by_jump_ends_chain(self): + # A man crowned during a jump stops; the new king may not + # keep jumping (here over c1) in the same turn. + state = make_state({"c3": "w", "b2": "b", "c1": "b"}) + state.apply(move("c3-a1")) + self.assertEqual(state.piece_at(0, 0), "W") + self.assertIsNone(state.must_continue) + self.assertEqual(state.turn, BLACK) + + +class MultipleCaptureTests(unittest.TestCase): + """Double jump: white d6 x c5 -> b4, then b4 x c3 -> d2.""" + + def setUp(self): + self.state = make_state({"d6": "w", "c5": "b", "c3": "b"}) + + def test_first_jump_keeps_turn_and_forces_same_piece(self): + self.state.apply(move("d6-b4")) + self.assertEqual(self.state.turn, WHITE) + self.assertEqual(self.state.must_continue, square_to_coords("b4")) + self.assertEqual(notations(self.state.legal_moves()), {"b4xd2"}) + + def test_chain_completes_and_switches_turn(self): + self.state.apply(move("d6-b4")) + self.state.apply(move("b4-d2")) + self.assertIsNone(self.state.must_continue) + self.assertEqual(self.state.turn, BLACK) + self.assertEqual(len(self.state.pieces(BLACK)), 0) + self.assertEqual(self.state.piece_at(1, 3), WHITE) # on d2 + + def test_other_piece_cannot_move_mid_chain(self): + state = make_state({"d6": "w", "c5": "b", "c3": "b", "h8": "w"}) + state.apply(move("d6-b4")) + with self.assertRaises(ValueError): + state.apply(move("h8-g7")) + + def test_capture_chains_reports_full_sequence(self): + chains = self.state.capture_chains(*square_to_coords("d6")) + self.assertEqual(len(chains), 1) + self.assertEqual([m.notation() for m in chains[0]], + ["d6xb4", "b4xd2"]) + + +class WinDetectionTests(unittest.TestCase): + def test_win_by_capturing_last_piece(self): + state = make_state({"d4": "w", "e3": "b"}) + state.apply(move("d4-f2")) + self.assertEqual(state.winner(), WHITE) + + def test_win_by_blocking_all_moves(self): + # Black a7 is boxed in: b8 occupied and its jump landing + # square c5 occupied, so it has no legal move. + state = make_state( + {"a7": "b", "b6": "w", "b8": "w", "c5": "w"}, turn=BLACK) + self.assertEqual(state.legal_moves(), []) + self.assertEqual(state.winner(), WHITE) + + def test_no_winner_mid_game(self): + self.assertIsNone(CheckersState().winner()) + + def test_no_winner_mid_chain(self): + state = make_state({"d6": "w", "c5": "b", "c3": "b"}) + state.apply(move("d6-b4")) + self.assertIsNone(state.winner()) + + +class ComputerTests(unittest.TestCase): + def test_prefers_most_captures(self): + # A two-jump chain from d6 beats the one-jump chain from h8. + state = make_state( + {"d6": "w", "c5": "b", "c3": "b", "h8": "w", "g7": "b"}) + chain = choose_computer_chain(state, WHITE, rng=random.Random(1)) + self.assertEqual([m.notation() for m in chain], + ["d6xb4", "b4xd2"]) + + def test_avoids_handing_a_capture(self): + # d4-e3 lets black f2 jump it; d4-c3 is safe. + state = make_state({"d4": "w", "f2": "b", "h8": "b"}) + rng = random.Random(2) + for _ in range(10): + chain = choose_computer_chain(state, WHITE, rng=rng) + self.assertEqual(chain[0].notation(), "d4-c3") + + def test_promotes_when_possible(self): + state = make_state({"b2": "w", "h8": "b"}) + chain = choose_computer_chain(state, WHITE, rng=random.Random(3)) + self.assertEqual(chain[0].notation(), "b2-a1") + state.apply(chain[0]) + self.assertEqual(state.piece_at(0, 0), "W") + + def test_returns_empty_when_no_moves(self): + state = make_state( + {"a7": "b", "b6": "w", "b8": "w", "c5": "w"}, turn=BLACK) + self.assertEqual(choose_computer_chain(state, BLACK), []) + + def test_opponent_helper(self): + self.assertEqual(opponent(WHITE), BLACK) + self.assertEqual(opponent(BLACK), WHITE) + + +if __name__ == "__main__": + unittest.main()
@@ -0,0 +1,175 @@+"""Headless tests for games.connect_four pure logic (Board, ComputerPlayer).""" +from __future__ import annotations + +import unittest + +from games.connect_four import ( + Board, + COLUMN_COUNT, + ComputerPlayer, + EMPTY, + PLAYER_ONE, + PLAYER_TWO, + ROW_COUNT, + other_player, +) + + +def fill_column(board: Board, column: int, pieces: list[int]) -> None: + """Drop pieces bottom-up in one column (list order = drop order).""" + for piece in pieces: + board.drop(column, piece) + + +class BoardBasicsTest(unittest.TestCase): + def test_new_board_is_empty(self): + board = Board() + self.assertEqual(board.moves, 0) + self.assertFalse(board.is_full()) + self.assertEqual(board.winner(), EMPTY) + self.assertFalse(board.is_draw()) + self.assertEqual(board.valid_columns(), list(range(COLUMN_COUNT))) + + def test_piece_falls_to_lowest_free_cell(self): + board = Board() + row = board.drop(3, PLAYER_ONE) + self.assertEqual(row, ROW_COUNT - 1) + self.assertEqual(board.grid[ROW_COUNT - 1][3], PLAYER_ONE) + row = board.drop(3, PLAYER_TWO) + self.assertEqual(row, ROW_COUNT - 2) + self.assertEqual(board.grid[ROW_COUNT - 2][3], PLAYER_TWO) + + def test_drop_rejects_out_of_range_column(self): + board = Board() + self.assertIsNone(board.drop(-1, PLAYER_ONE)) + self.assertIsNone(board.drop(COLUMN_COUNT, PLAYER_ONE)) + self.assertEqual(board.moves, 0) + + def test_full_column_rejects_drop(self): + board = Board() + for _ in range(ROW_COUNT): + self.assertIsNotNone(board.drop(0, PLAYER_ONE)) + self.assertIsNone(board.drop(0, PLAYER_ONE)) + self.assertFalse(board.is_valid_column(0)) + self.assertNotIn(0, board.valid_columns()) + + def test_other_player(self): + self.assertEqual(other_player(PLAYER_ONE), PLAYER_TWO) + self.assertEqual(other_player(PLAYER_TWO), PLAYER_ONE) + + +class WinDetectionTest(unittest.TestCase): + def test_horizontal_win(self): + board = Board() + for col in range(4): + board.drop(col, PLAYER_ONE) + self.assertTrue(board.has_won(PLAYER_ONE)) + self.assertEqual(board.winner(), PLAYER_ONE) + + def test_no_horizontal_win_with_only_three(self): + board = Board() + for col in range(3): + board.drop(col, PLAYER_ONE) + self.assertFalse(board.has_won(PLAYER_ONE)) + + def test_vertical_win(self): + board = Board() + for _ in range(4): + board.drop(2, PLAYER_TWO) + self.assertTrue(board.has_won(PLAYER_TWO)) + self.assertEqual(board.winner(), PLAYER_TWO) + + def test_ascending_diagonal_win(self): + # Bottom-left to top-right: columns 0..3 with rising heights. + board = Board() + fill_column(board, 0, [PLAYER_ONE]) + fill_column(board, 1, [PLAYER_TWO, PLAYER_ONE]) + fill_column(board, 2, [PLAYER_TWO, PLAYER_TWO, PLAYER_ONE]) + fill_column(board, 3, [PLAYER_TWO, PLAYER_TWO, PLAYER_TWO, PLAYER_ONE]) + self.assertTrue(board.has_won(PLAYER_ONE)) + self.assertEqual(board.winner(), PLAYER_ONE) + + def test_descending_diagonal_win(self): + # Top-left to bottom-right: columns 3..0 with falling heights. + board = Board() + fill_column(board, 3, [PLAYER_ONE]) + fill_column(board, 2, [PLAYER_TWO, PLAYER_ONE]) + fill_column(board, 1, [PLAYER_TWO, PLAYER_TWO, PLAYER_ONE]) + fill_column(board, 0, [PLAYER_TWO, PLAYER_TWO, PLAYER_TWO, PLAYER_ONE]) + self.assertTrue(board.has_won(PLAYER_ONE)) + self.assertEqual(board.winner(), PLAYER_ONE) + + def test_diagonal_run_of_three_is_not_a_win(self): + board = Board() + fill_column(board, 0, [PLAYER_ONE]) + fill_column(board, 1, [PLAYER_TWO, PLAYER_ONE]) + fill_column(board, 2, [PLAYER_TWO, PLAYER_TWO, PLAYER_ONE]) + self.assertFalse(board.has_won(PLAYER_ONE)) + + def test_full_board_draw(self): + # Fill the board in a pattern with no four in a row: + # alternating columns get alternating pieces, offset so that + # horizontal and diagonal runs never reach four. + board = Board() + start_one = [PLAYER_ONE, PLAYER_TWO, PLAYER_ONE, + PLAYER_TWO, PLAYER_ONE, PLAYER_TWO] + start_two = [PLAYER_TWO, PLAYER_ONE, PLAYER_TWO, + PLAYER_ONE, PLAYER_TWO, PLAYER_ONE] + for col in range(COLUMN_COUNT): + pattern = start_one if col in (0, 3, 6) else start_two + fill_column(board, col, pattern) + self.assertTrue(board.is_full()) + self.assertEqual(board.winner(), EMPTY) + self.assertTrue(board.is_draw()) + + def test_full_board_with_winner_is_not_a_draw(self): + board = Board() + for _ in range(4): + board.drop(0, PLAYER_ONE) + self.assertFalse(board.is_draw()) + + +class ComputerPlayerTest(unittest.TestCase): + def setUp(self): + self.ai = ComputerPlayer(PLAYER_TWO) + + def test_takes_winning_move(self): + board = Board() + for _ in range(3): + board.drop(1, PLAYER_TWO) + self.assertEqual(self.ai.choose_column(board), 1) + + def test_blocks_opponent_win(self): + board = Board() + for _ in range(3): + board.drop(4, PLAYER_ONE) + self.assertEqual(self.ai.choose_column(board), 4) + + def test_winning_beats_blocking(self): + board = Board() + for _ in range(3): + board.drop(1, PLAYER_TWO) # AI can win here + board.drop(5, PLAYER_ONE) # opponent also threatens + self.assertEqual(self.ai.choose_column(board), 1) + + def test_prefers_center_on_empty_board(self): + board = Board() + self.assertEqual(self.ai.choose_column(board), 3) + + def test_prefers_next_center_when_center_full(self): + board = Board() + # Alternating pieces: a full column with no winner. + for i in range(ROW_COUNT): + board.drop(3, PLAYER_ONE if i % 2 == 0 else PLAYER_TWO) + self.assertEqual(self.ai.choose_column(board), 2) + + def test_never_picks_full_column(self): + board = Board() + for _ in range(ROW_COUNT): + board.drop(3, PLAYER_ONE) + for _ in range(30): + self.assertNotEqual(self.ai.choose_column(board), 3) + + +if __name__ == "__main__": + unittest.main()
@@ -0,0 +1,172 @@+"""Headless unit tests for games.fifteen (pure Board logic only).""" +from __future__ import annotations + +import random +import unittest + +from games.fifteen import Board, DIRECTIONS, OPPOSITE + + +def solved(): + return Board.solved_tiles() + + +class BoardInitTests(unittest.TestCase): + def test_default_board_is_solved(self): + board = Board() + self.assertTrue(board.is_solved()) + self.assertEqual(board.tiles, solved()) + + def test_explicit_tiles_accepted(self): + tiles = solved() + tiles[0], tiles[1] = tiles[1], tiles[0] + board = Board(tiles) + self.assertEqual(board.tiles, tiles) + self.assertFalse(board.is_solved()) + + def test_invalid_tiles_rejected(self): + with self.assertRaises(ValueError): + Board([1, 2, 3]) + with self.assertRaises(ValueError): + Board([0] * 16) + + def test_move_count_starts_at_zero(self): + self.assertEqual(Board().move_count, 0) + + +class MovementTests(unittest.TestCase): + def test_move_slides_tile_into_gap(self): + # Solved board: blank at bottom-right (row 3, col 3). + board = Board() + # "DOWN" = tile above the gap (tile 12) slides down into it. + self.assertTrue(board.move("DOWN")) + self.assertEqual(board.blank_position(), (2, 3)) + self.assertEqual(board.tile_at(3, 3), 12) + self.assertEqual(board.tile_at(2, 3), 0) + self.assertEqual(board.move_count, 1) + + def test_move_right_slides_left_neighbor(self): + board = Board() + # "RIGHT" = tile left of the gap (tile 15) slides right. + self.assertTrue(board.move("RIGHT")) + self.assertEqual(board.blank_position(), (3, 2)) + self.assertEqual(board.tile_at(3, 3), 15) + + def test_move_up_slides_tile_below_gap(self): + board = Board() + board.move("DOWN") # gap now at (2, 3) + self.assertTrue(board.move("UP")) # tile 12 slides back up + self.assertTrue(board.is_solved()) + + def test_impossible_moves_rejected(self): + board = Board() + # Blank in the bottom-right corner: no tile below or right of it. + before = list(board.tiles) + self.assertFalse(board.move("UP")) + self.assertFalse(board.move("LEFT")) + self.assertEqual(board.tiles, before) + self.assertEqual(board.move_count, 0) + + def test_unknown_direction_rejected(self): + board = Board() + self.assertFalse(board.move("SIDEWAYS")) + self.assertFalse(board.can_move("SIDEWAYS")) + + def test_can_move_matches_geometry(self): + board = Board() + self.assertEqual(sorted(board.legal_moves()), ["DOWN", "RIGHT"]) + board.move("DOWN") + self.assertEqual(sorted(board.legal_moves()), ["DOWN", "RIGHT", "UP"]) + + def test_moves_preserve_tile_set(self): + board = Board() + rng = random.Random(42) + for _ in range(50): + board.move(rng.choice(board.legal_moves())) + self.assertEqual(sorted(board.tiles), list(range(16))) + + def test_opposites_cover_all_directions(self): + self.assertEqual(sorted(OPPOSITE), sorted(DIRECTIONS)) + for direction, opposite in OPPOSITE.items(): + self.assertEqual(OPPOSITE[opposite], direction) + + +class SolvedDetectionTests(unittest.TestCase): + def test_solved_state(self): + self.assertTrue(Board().is_solved()) + + def test_one_move_away_is_not_solved(self): + board = Board() + board.move("DOWN") + self.assertFalse(board.is_solved()) + + def test_swapped_tiles_not_solved(self): + tiles = solved() + tiles[0], tiles[1] = tiles[1], tiles[0] + self.assertFalse(Board(tiles).is_solved()) + + def test_blank_must_be_last(self): + tiles = [0] + list(range(1, 16)) # ordered but blank first + self.assertFalse(Board(tiles).is_solved()) + + +class SolvabilityTests(unittest.TestCase): + def test_solved_board_is_solvable(self): + self.assertTrue(Board.is_solvable(solved())) + + def test_known_solvable_board(self): + # One legal move (tile 12 down) from the goal state. + tiles = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 0, 13, 14, 15, 12] + self.assertTrue(Board.is_solvable(tiles)) + + def test_known_unsolvable_board(self): + # Classic impossible position: only tiles 14 and 15 swapped. + tiles = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 15, 14, 0] + self.assertFalse(Board.is_solvable(tiles)) + + def test_single_swap_of_first_two_tiles_unsolvable(self): + tiles = solved() + tiles[0], tiles[1] = tiles[1], tiles[0] + self.assertFalse(Board.is_solvable(tiles)) + + def test_every_legal_move_keeps_solvability(self): + # Legal moves from a solvable board must stay solvable. + board = Board() + rng = random.Random(7) + for _ in range(100): + self.assertTrue(Board.is_solvable(board.tiles)) + board.move(rng.choice(board.legal_moves())) + + def test_inversion_count(self): + self.assertEqual(Board.count_inversions(solved()), 0) + self.assertEqual(Board.count_inversions([2, 1] + list(range(3, 16)) + [0]), 1) + + +class ShuffleTests(unittest.TestCase): + def test_generated_boards_are_always_solvable(self): + for seed in range(20): + board = Board.shuffled(rng=random.Random(seed)) + self.assertTrue( + Board.is_solvable(board.tiles), + f"seed {seed} produced an unsolvable board", + ) + + def test_generated_boards_are_not_already_solved(self): + for seed in range(10): + self.assertFalse(Board.shuffled(rng=random.Random(seed)).is_solved()) + + def test_generated_boards_contain_all_tiles(self): + board = Board.shuffled(rng=random.Random(1)) + self.assertEqual(sorted(board.tiles), list(range(16))) + + def test_shuffle_resets_move_count(self): + board = Board.shuffled(rng=random.Random(1)) + self.assertEqual(board.move_count, 0) + + def test_shuffle_accepts_short_scramble(self): + board = Board.shuffled(rng=random.Random(3), scramble_moves=10) + self.assertTrue(Board.is_solvable(board.tiles)) + + +if __name__ == "__main__": + unittest.main()
@@ -0,0 +1,154 @@+#!/usr/bin/env python3 +"""Headless unit tests for games/gomoku.py (pure logic only).""" +from __future__ import annotations + +import unittest + +from games.gomoku import GomokuAI, GomokuBoard, EMPTY, X, O + + +def play(board: GomokuBoard, moves: list[tuple[int, int]]) -> None: + """Play a list of alternating moves, asserting each one is legal.""" + for row, col in moves: + assert board.place(row, col), f"move {(row, col)} should be legal" + + +class WinDetectionTests(unittest.TestCase): + """Five (or more) consecutive stones win, in every direction.""" + + def test_five_horizontal_wins(self): + board = GomokuBoard() + play(board, [(7, 3), (0, 0), (7, 4), (0, 1), (7, 5), (0, 2), + (7, 6), (0, 3), (7, 7)]) + self.assertEqual(board.winner, X) + self.assertTrue(board.is_over()) + + def test_five_vertical_wins(self): + board = GomokuBoard() + play(board, [(3, 4), (0, 0), (4, 4), (0, 1), (5, 4), (0, 2), + (6, 4), (0, 3), (7, 4)]) + self.assertEqual(board.winner, X) + + def test_five_diagonal_down_right_wins(self): + board = GomokuBoard() + play(board, [(2, 2), (0, 0), (3, 3), (0, 1), (4, 4), (0, 2), + (5, 5), (0, 3), (6, 6)]) + self.assertEqual(board.winner, X) + + def test_five_diagonal_up_right_wins(self): + board = GomokuBoard() + play(board, [(6, 2), (0, 0), (5, 3), (0, 1), (4, 4), (0, 2), + (3, 5), (0, 3), (2, 6)]) + self.assertEqual(board.winner, X) + + def test_overline_six_in_a_row_wins(self): + # X leaves a gap at (7, 7); filling it last creates six in a row. + board = GomokuBoard() + play(board, [(7, 3), (0, 0), (7, 4), (0, 1), (7, 5), (0, 2), + (7, 6), (0, 3), (7, 8), (1, 0), (7, 7)]) + self.assertEqual(board.winner, X) + # The middle stone of the six is also part of the winning line. + self.assertTrue(board.wins_through(7, 5)) + + def test_four_in_a_row_is_not_a_win(self): + board = GomokuBoard() + play(board, [(7, 3), (0, 0), (7, 4), (0, 1), (7, 5), (0, 2), (7, 6)]) + self.assertEqual(board.winner, EMPTY) + self.assertFalse(board.is_over()) + + def test_o_can_win(self): + board = GomokuBoard() + play(board, [(0, 0), (7, 3), (0, 1), (7, 4), (0, 2), (7, 5), + (0, 3), (7, 6), (14, 14), (7, 7)]) + self.assertEqual(board.winner, O) + + +class BoardMechanicsTests(unittest.TestCase): + def test_players_alternate_starting_with_x(self): + board = GomokuBoard() + self.assertEqual(board.current_player, X) + board.place(7, 7) + self.assertEqual(board.current_player, O) + board.place(0, 0) + self.assertEqual(board.current_player, X) + + def test_occupied_square_is_rejected(self): + board = GomokuBoard() + self.assertTrue(board.place(7, 7)) + self.assertFalse(board.place(7, 7)) + self.assertEqual(board.grid[7][7], X) + self.assertEqual(board.current_player, O) # failed move changes nothing + + def test_out_of_bounds_move_is_rejected(self): + board = GomokuBoard() + self.assertFalse(board.place(-1, 0)) + self.assertFalse(board.place(15, 0)) + self.assertFalse(board.place(0, 15)) + + def test_no_moves_after_win(self): + board = GomokuBoard() + play(board, [(7, 3), (0, 0), (7, 4), (0, 1), (7, 5), (0, 2), + (7, 6), (0, 3), (7, 7)]) + self.assertFalse(board.place(10, 10)) + + def test_parse_coordinate_valid(self): + self.assertEqual(GomokuBoard.parse_coordinate("h8"), (7, 7)) + self.assertEqual(GomokuBoard.parse_coordinate("A1"), (0, 0)) + self.assertEqual(GomokuBoard.parse_coordinate("o15"), (14, 14)) + self.assertEqual(GomokuBoard.parse_coordinate(" c3 "), (2, 2)) + + def test_parse_coordinate_invalid(self): + for bad in ("", "h", "8", "8h", "hh", "p1", "h0", "h16", "h-1", "a 1 2"): + self.assertIsNone(GomokuBoard.parse_coordinate(bad), bad) + + def test_format_coordinate_roundtrip(self): + self.assertEqual(GomokuBoard.format_coordinate(7, 7), "H8") + self.assertEqual(GomokuBoard.parse_coordinate( + GomokuBoard.format_coordinate(10, 4)), (10, 4)) + + +class AITests(unittest.TestCase): + def test_ai_completes_its_own_five(self): + board = GomokuBoard() + play(board, [(0, 0), (3, 3), (0, 1), (3, 4), (1, 0), (3, 5), + (1, 1), (3, 6)]) + move = GomokuAI(O).choose_move(board) + self.assertIn(move, [(3, 2), (3, 7)]) + + def test_ai_blocks_immediate_win(self): + board = GomokuBoard() + play(board, [(7, 3), (0, 0), (7, 4), (0, 1), (7, 5), (1, 0), + (7, 6), (1, 1)]) + move = GomokuAI(O).choose_move(board) + self.assertIn(move, [(7, 2), (7, 7)]) + + def test_ai_prefers_winning_over_blocking(self): + # Both sides have four in a row; the AI must take its own win. + board = GomokuBoard() + play(board, [(0, 0), (3, 3), (0, 1), (3, 4), (0, 2), (3, 5), + (0, 3), (3, 6)]) + move = GomokuAI(O).choose_move(board) + self.assertIn(move, [(3, 2), (3, 7)]) + + def test_ai_opens_in_the_center(self): + board = GomokuBoard() + self.assertEqual(GomokuAI(O).choose_move(board), (7, 7)) + + def test_ai_plays_near_existing_stones(self): + board = GomokuBoard() + play(board, [(7, 7), (14, 14)]) + move = GomokuAI(O).choose_move(board) + self.assertIsNotNone(move) + row, col = move + self.assertLessEqual(max(abs(row - 7), abs(col - 7)), GomokuAI.CANDIDATE_RANGE) + self.assertTrue(board.is_empty(row, col)) + + def test_ai_returns_none_when_game_over(self): + board = GomokuBoard() + play(board, [(7, 3), (0, 0), (7, 4), (0, 1), (7, 5), (0, 2), + (7, 6), (0, 3), (7, 7)]) + self.assertIsNone(GomokuAI(O).choose_move(board)) + + +if __name__ == "__main__": + unittest.main()
@@ -0,0 +1,107 @@+"""Tests for core.input: read_line edge cases and read_key normalization. + +read_key has two platform branches chosen at import time (msvcrt on +Windows, termios/select on Unix). Each branch is tested by patching +its own low-level dependency; only the branch active on the current +platform runs. +""" +from __future__ import annotations + +import io +import unittest +from contextlib import redirect_stdout +from unittest import mock + +from core import input as core_input +from core.input import Key, read_key, read_line + + +class ReadLineTest(unittest.TestCase): + def test_returns_typed_line_and_forwards_prompt(self): + with mock.patch("builtins.input", return_value="b2") as m: + self.assertEqual(read_line("Move: "), "b2") + m.assert_called_once_with("Move: ") + + def test_eof_returns_none(self): + with mock.patch("builtins.input", side_effect=EOFError): + with redirect_stdout(io.StringIO()): + self.assertIsNone(read_line()) + + def test_ctrl_c_returns_none(self): + with mock.patch("builtins.input", side_effect=KeyboardInterrupt): + with redirect_stdout(io.StringIO()): + self.assertIsNone(read_line()) + + +if hasattr(core_input, "msvcrt"): # Windows branch + + class ReadKeyWindowsTest(unittest.TestCase): + def _feed(self, *chars): + return mock.patch.object(core_input.msvcrt, "getwch", + side_effect=chars) + + def test_plain_char(self): + with self._feed("a"): + self.assertEqual(read_key(), "a") + + def test_arrows_are_normalized(self): + for code, expected in (("H", Key.UP), ("P", Key.DOWN), + ("K", Key.LEFT), ("M", Key.RIGHT)): + with self.subTest(code=code), self._feed("\xe0", code): + self.assertEqual(read_key(), expected) + + def test_enter_escape_backspace(self): + for ch, expected in (("\r", Key.ENTER), ("\x1b", Key.ESCAPE), + ("\x08", Key.BACKSPACE)): + with self.subTest(ch=repr(ch)), self._feed(ch): + self.assertEqual(read_key(), expected) + + def test_unknown_special_key_falls_back_to_empty(self): + with self._feed("\x00", "S"): # e.g. F-keys: no mapping + self.assertEqual(read_key(), "") + +else: # Unix branch + + class ReadKeyUnixTest(unittest.TestCase): + def _run(self, reads, sequence_ready=False): + fake_stdin = mock.Mock() + fake_stdin.fileno.return_value = 0 + fake_stdin.read.side_effect = reads + with mock.patch.object(core_input.sys, "stdin", fake_stdin), \ + mock.patch.object(core_input.termios, "tcgetattr", + return_value="saved"), \ + mock.patch.object(core_input.termios, "tcsetattr"), \ + mock.patch.object(core_input.tty, "setraw"), \ + mock.patch.object(core_input.select, "select", + return_value=([fake_stdin] if sequence_ready + else [], [], [])): + return read_key() + + def test_plain_char(self): + self.assertEqual(self._run(["a"]), "a") + + def test_arrows_are_normalized(self): + for seq, expected in (("[A", Key.UP), ("[B", Key.DOWN), + ("[C", Key.RIGHT), ("[D", Key.LEFT)): + with self.subTest(seq=seq): + self.assertEqual(self._run(["\x1b", seq], + sequence_ready=True), expected) + + def test_lone_escape(self): + # No bytes available right after ESC: it was a real ESC press. + self.assertEqual(self._run(["\x1b"], sequence_ready=False), + Key.ESCAPE) + + def test_unknown_sequence_falls_back_to_escape(self): + self.assertEqual(self._run(["\x1b", "[Z"], sequence_ready=True), + Key.ESCAPE) + + def test_enter_variants_and_backspace(self): + for ch, expected in (("\r", Key.ENTER), ("\n", Key.ENTER), + ("\x7f", Key.BACKSPACE)): + with self.subTest(ch=repr(ch)): + self.assertEqual(self._run([ch]), expected) + + +if __name__ == "__main__": + unittest.main()
@@ -0,0 +1,212 @@+"""Headless unit tests for games.mancala (Kalah rules). + +These tests drive only the pure logic classes (KalahBoard, +ComputerPlayer); nothing here touches the terminal. +""" +import random +import unittest + +from games.mancala import ( + DRAW, + HUMAN, + INITIAL_STONES, + OPPONENT, + PITS_PER_SIDE, + SLOT_COUNT, + ComputerPlayer, + KalahBoard, +) + +TOTAL_STONES = 2 * PITS_PER_SIDE * INITIAL_STONES # 48 + + +def board_with(slots, current=HUMAN): + return KalahBoard(slots=slots, current=current) + + +class InitialBoardTests(unittest.TestCase): + def test_fresh_board_layout(self): + board = KalahBoard() + self.assertEqual(board.pits(HUMAN), [INITIAL_STONES] * PITS_PER_SIDE) + self.assertEqual(board.pits(OPPONENT), [INITIAL_STONES] * PITS_PER_SIDE) + self.assertEqual(board.store(HUMAN), 0) + self.assertEqual(board.store(OPPONENT), 0) + self.assertEqual(board.current, HUMAN) + self.assertFalse(board.game_over) + self.assertIsNone(board.winner) + self.assertEqual(sum(board.slots), TOTAL_STONES) + + def test_legal_moves_are_the_non_empty_own_pits(self): + board = board_with([0, 3, 0, 1, 0, 2, 0, 4, 0, 5, 0, 6, 0, 0]) + self.assertEqual(board.legal_moves(HUMAN), [2, 4, 6]) + self.assertEqual(board.legal_moves(OPPONENT), [1, 3, 5]) + + def test_illegal_moves_raise(self): + board = board_with([0, 4, 4, 4, 4, 4, 0, 4, 4, 4, 4, 4, 4, 0]) + with self.assertRaises(ValueError): + board.apply_move(1) # empty pit + with self.assertRaises(ValueError): + board.apply_move(0) # out of range + with self.assertRaises(ValueError): + board.apply_move(7) # out of range + + def test_move_on_finished_game_raises(self): + board = board_with([0, 0, 0, 0, 0, 1, 24, 0, 0, 0, 0, 0, 0, 24]) + board.apply_move(6) # ends the game (human side empty) + self.assertTrue(board.game_over) + with self.assertRaises(ValueError): + board.apply_move(1) + + +class SowingTests(unittest.TestCase): + def test_basic_sowing_distribution(self): + board = KalahBoard() + result = board.apply_move(2) # 4 stones from pit 2 into pits 3-6 + self.assertEqual(board.slots[1], 0) # source emptied + self.assertEqual(board.slots[2:6], [5, 5, 5, 5]) # one stone each + self.assertEqual(board.store(HUMAN), 0) # none reached it + self.assertFalse(result.extra_turn) + self.assertEqual(board.current, OPPONENT) # turn passed + self.assertEqual(sum(board.slots), TOTAL_STONES) + + def test_sowing_reaches_own_store(self): + board = KalahBoard() + result = board.apply_move(3) # 4 stones: pits 4, 5, 6 and store + self.assertEqual(board.store(HUMAN), 1) + self.assertTrue(result.extra_turn) + + def test_sowing_skips_opponent_store_human(self): + # 10 stones in pit 6: wraps past the opponent's side and store. + # Slot 2 starts non-empty so the wrapped last stone cannot capture. + board = board_with([0, 0, 1, 0, 0, 10, 0, 4, 4, 4, 4, 4, 4, 0]) + board.apply_move(6) + self.assertEqual(board.store(OPPONENT), 0) # never sown into + self.assertEqual(board.store(HUMAN), 1) + self.assertEqual(board.slots[7:13], [5, 5, 5, 5, 5, 5]) + self.assertEqual(board.slots[0:3], [1, 1, 2]) # wrapped around + self.assertEqual(sum(board.slots), 35) + + def test_sowing_skips_opponent_store_opponent(self): + # Mirror image for the opponent sowing past the human store. + board = board_with([4, 4, 4, 4, 4, 4, 0, 0, 0, 1, 0, 0, 10, 0], + current=OPPONENT) + board.apply_move(6) + self.assertEqual(board.store(HUMAN), 0) + self.assertEqual(board.store(OPPONENT), 1) + self.assertEqual(board.slots[0:6], [5, 5, 5, 5, 5, 5]) + self.assertEqual(board.slots[7:10], [1, 1, 2]) + self.assertEqual(sum(board.slots), 35) + + +class ExtraTurnTests(unittest.TestCase): + def test_last_stone_in_own_store_grants_extra_turn(self): + board = KalahBoard() + result = board.apply_move(3) # last of 4 stones lands in the store + self.assertTrue(result.extra_turn) + self.assertEqual(board.current, HUMAN) # same player moves again + + def test_no_extra_turn_when_last_stone_lands_in_a_pit(self): + board = KalahBoard() + result = board.apply_move(1) # lands in pit 5 + self.assertFalse(result.extra_turn) + self.assertEqual(board.current, OPPONENT) + + +class CaptureTests(unittest.TestCase): + def test_capture_into_empty_own_pit(self): + # Pit 2 has 1 stone; pit 3 is empty; the opposite pit holds 5. + board = board_with([1, 1, 0, 0, 0, 0, 0, 1, 0, 0, 5, 0, 0, 0]) + result = board.apply_move(2) # last stone lands in empty pit 3 + self.assertEqual(result.captured, 6) # 5 opposite + own stone + self.assertEqual(board.store(HUMAN), 6) + self.assertEqual(board.slots[2], 0) # own pit emptied + self.assertEqual(board.slots[10], 0) # opposite pit emptied + self.assertFalse(result.extra_turn) + self.assertEqual(board.current, OPPONENT) + self.assertFalse(board.game_over) + + def test_no_capture_when_opposite_pit_is_empty(self): + board = board_with([1, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0]) + result = board.apply_move(2) + self.assertEqual(result.captured, 0) + self.assertEqual(board.slots[2], 1) # the stone simply stays + self.assertEqual(board.store(HUMAN), 0) + + def test_no_capture_on_opponent_side(self): + # A stone landing in an empty opponent pit captures nothing. + # Slot 0 keeps the human side non-empty so the game continues. + board = board_with([1, 0, 0, 0, 0, 7, 0, 0, 4, 4, 4, 4, 4, 0]) + result = board.apply_move(6) # last stone lands in an opponent pit + self.assertEqual(result.captured, 0) + self.assertFalse(result.game_over) + self.assertEqual(board.store(HUMAN), 1) # only the sown stone + self.assertEqual(board.slots[7], 1) + self.assertEqual(board.slots[8:13], [5, 5, 5, 5, 5]) + + +class EndGameTests(unittest.TestCase): + def test_end_game_collection_and_scoring(self): + # Human plays the last stone of their side into the store. + board = board_with([0, 0, 0, 0, 0, 1, 5, 1, 2, 3, 4, 5, 6, 10]) + result = board.apply_move(6) + self.assertTrue(result.game_over) + self.assertTrue(board.game_over) + # Remaining opponent stones move to the opponent's store. + self.assertEqual(board.pits(OPPONENT), [0] * PITS_PER_SIDE) + self.assertEqual(board.store(OPPONENT), 10 + 1 + 2 + 3 + 4 + 5 + 6) + self.assertEqual(board.store(HUMAN), 6) + self.assertEqual(board.winner, OPPONENT) + self.assertEqual(sum(board.slots), 37) + + def test_game_can_end_by_capture_emptying_a_side(self): + # The captured pit was the opponent's last stone: game over, and + # the human's leftover stone is collected into the human store. + board = board_with([1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 5, 0, 0, 0]) + result = board.apply_move(2) + self.assertEqual(result.captured, 6) + self.assertTrue(result.game_over) + self.assertEqual(board.store(HUMAN), 7) # 6 captured + 1 leftover + self.assertEqual(board.store(OPPONENT), 0) + self.assertEqual(board.winner, HUMAN) + + def test_equal_stores_are_a_draw(self): + board = board_with([0, 0, 0, 0, 0, 1, 23, 0, 0, 0, 0, 0, 2, 22]) + board.apply_move(6) + self.assertTrue(board.game_over) + self.assertEqual(board.store(HUMAN), 24) + self.assertEqual(board.store(OPPONENT), 24) + self.assertEqual(board.winner, DRAW) + + def test_game_continues_while_both_sides_have_stones(self): + board = KalahBoard() + board.apply_move(3) # extra turn, nothing ends + self.assertFalse(board.game_over) + self.assertIsNone(board.winner) + + +class ComputerPlayerTests(unittest.TestCase): + def test_prefers_extra_turn(self): + board = KalahBoard() + ai = ComputerPlayer(random.Random(1)) + # Pit 3 is the only opening move landing in the store. + self.assertEqual(ai.choose_move(board), 3) + + def test_prefers_capture_over_plain_store_gain(self): + # Pit 3 captures 5 stones; pit 6 only sows one into the store. + board = board_with([0, 0, 5, 0, 0, 0, 0, 0, 0, 1, 0, 0, 3, 0]) + ai = ComputerPlayer(random.Random(2)) + self.assertEqual(ai.choose_move(board), 3) + + def test_returns_a_legal_move(self): + board = KalahBoard() + ai = ComputerPlayer(random.Random(3)) + for pit in (3, 1, 6, 4, 2, 5): # play a few full turns + if board.game_over: + break + move = ai.choose_move(board) if board.current == OPPONENT else pit + self.assertIn(move, board.legal_moves()) + board.apply_move(move) + + +if __name__ == "__main__": + unittest.main()
@@ -0,0 +1,199 @@+#!/usr/bin/env python3 +"""Headless tests for games.minesweeper (Board logic only).""" +from __future__ import annotations + +import random +import unittest + +from games.minesweeper import Board, DIFFICULTIES + + +class TestDifficultyTable(unittest.TestCase): + def test_presets_match_the_spec(self): + easy = DIFFICULTIES["easy"] + self.assertEqual((easy.rows, easy.cols, easy.mines), (9, 9, 10)) + medium = DIFFICULTIES["medium"] + self.assertEqual((medium.rows, medium.cols, medium.mines), (16, 16, 40)) + hard = DIFFICULTIES["hard"] + self.assertEqual((hard.rows, hard.cols, hard.mines), (18, 22, 85)) + + def test_record_keys(self): + self.assertEqual(DIFFICULTIES["easy"].record_key, + "minesweeper_best_time_easy") + self.assertEqual(DIFFICULTIES["medium"].record_key, + "minesweeper_best_time_medium") + self.assertEqual(DIFFICULTIES["hard"].record_key, + "minesweeper_best_time_hard") + + +class TestMinePlacement(unittest.TestCase): + def test_mine_count_after_generation(self): + for diff in DIFFICULTIES.values(): + board = Board(diff.rows, diff.cols, diff.mines) + board.place_mines(0, 0) + self.assertEqual(len(board.mines), diff.mines) + self.assertTrue(board.mines_placed) + for r, c in board.mines: + self.assertTrue(0 <= r < diff.rows) + self.assertTrue(0 <= c < diff.cols) + + def test_generation_is_reproducible_with_injected_rng(self): + a = Board(9, 9, 10, rng=random.Random(42)) + b = Board(9, 9, 10, rng=random.Random(42)) + self.assertEqual(a.place_mines(4, 4), b.place_mines(4, 4)) + + def test_first_move_safety(self): + """The first revealed cell and its neighbors are never mines.""" + for trial in range(40): + board = Board(9, 9, 10) + safe_r, safe_c = trial % 9, (trial * 3) % 9 + board.place_mines(safe_r, safe_c) + self.assertNotIn((safe_r, safe_c), board.mines) + for nb in board.neighbors(safe_r, safe_c): + self.assertNotIn(nb, board.mines) + + def test_first_move_safety_via_reveal(self): + for _ in range(20): + board = Board(16, 16, 40) + board.reveal(7, 8) + self.assertTrue(board.mines_placed) + self.assertNotIn((7, 8), board.mines) + for nb in board.neighbors(7, 8): + self.assertNotIn(nb, board.mines) + + def test_first_move_safety_in_corner(self): + for _ in range(10): + board = Board(9, 9, 10) + board.reveal(0, 0) + protected = {(0, 0)} | set(board.neighbors(0, 0)) + self.assertTrue(board.mines.isdisjoint(protected)) + + def test_place_mines_twice_is_rejected(self): + board = Board(9, 9, 10) + board.place_mines(4, 4) + with self.assertRaises(RuntimeError): + board.place_mines(0, 0) + + def test_too_many_mines_is_rejected(self): + board = Board(3, 3, 1) + with self.assertRaises(ValueError): + board.place_mines(1, 1) # every cell is protected + + def test_fixed_mines_outside_board_are_rejected(self): + with self.assertRaises(ValueError): + Board(5, 5, mines={(5, 0)}) + + +class TestNeighborCounts(unittest.TestCase): + def setUp(self): + self.mines = {(0, 0), (0, 4), (2, 2)} + self.board = Board(5, 5, mines=self.mines) + + def test_fixed_mines_are_used_verbatim(self): + self.assertTrue(self.board.mines_placed) + self.assertEqual(self.board.mines, self.mines) + self.assertEqual(self.board.mine_count, 3) + + def test_counts_on_known_layout(self): + self.assertEqual(self.board.neighbor_mine_count(1, 1), 2) # (0,0),(2,2) + self.assertEqual(self.board.neighbor_mine_count(1, 3), 2) # (0,4),(2,2) + self.assertEqual(self.board.neighbor_mine_count(0, 1), 1) # (0,0) + self.assertEqual(self.board.neighbor_mine_count(3, 1), 1) # (2,2) + self.assertEqual(self.board.neighbor_mine_count(4, 4), 0) + self.assertEqual(self.board.neighbor_mine_count(4, 0), 0) + + +class TestFloodReveal(unittest.TestCase): + def setUp(self): + # One full row of mines at the top; everything below row 1 is empty. + self.mines = {(0, c) for c in range(9)} + self.board = Board(9, 9, mines=self.mines) + + def test_flood_opens_whole_empty_region(self): + newly = self.board.reveal(8, 4) + expected = {(r, c) for r in range(1, 9) for c in range(9)} + self.assertEqual(newly, expected) + self.assertEqual(self.board.revealed, expected) + + def test_flood_stops_at_numbered_border_and_mines(self): + self.board.reveal(8, 4) + for c in range(9): + self.assertNotIn((0, c), self.board.revealed) # mines stay hidden + self.assertEqual(self.board.neighbor_mine_count(1, 0), 2) + self.assertEqual(self.board.neighbor_mine_count(1, 4), 3) + self.assertEqual(self.board.neighbor_mine_count(1, 8), 2) + + def test_flood_reveal_wins_when_only_mines_remain(self): + self.board.reveal(8, 4) + self.assertTrue(self.board.is_won) + self.assertFalse(self.board.lost) + + +class TestFlags(unittest.TestCase): + def setUp(self): + self.board = Board(9, 9, mines={(0, 0)}) + + def test_toggle_flag_and_counter(self): + self.assertEqual(self.board.flags_remaining, 1) + self.assertTrue(self.board.toggle_flag(5, 5)) + self.assertEqual(self.board.flags_remaining, 0) + self.assertFalse(self.board.toggle_flag(5, 5)) + self.assertEqual(self.board.flags_remaining, 1) + + def test_flagged_cell_cannot_be_revealed(self): + self.board.toggle_flag(5, 5) + self.assertEqual(self.board.reveal(5, 5), set()) + self.assertNotIn((5, 5), self.board.revealed) + + def test_flag_on_revealed_cell_is_refused(self): + self.board.reveal(5, 5) + self.assertIsNone(self.board.toggle_flag(5, 5)) + self.assertNotIn((5, 5), self.board.flagged) + + def test_flag_outside_board_is_refused(self): + self.assertIsNone(self.board.toggle_flag(-1, 0)) + self.assertIsNone(self.board.toggle_flag(9, 9)) + + +class TestWinAndLoss(unittest.TestCase): + def test_revealing_a_mine_loses_and_exposes_all_mines(self): + mines = {(0, 0), (0, 1), (4, 4)} + board = Board(5, 5, mines=mines) + newly = board.reveal(0, 0) + self.assertTrue(board.lost) + self.assertEqual(board.exploded_at, (0, 0)) + self.assertTrue(mines.issubset(board.revealed)) + self.assertEqual(newly, mines) + self.assertFalse(board.is_won) + + def test_no_actions_after_loss(self): + board = Board(5, 5, mines={(0, 0)}) + board.reveal(0, 0) + self.assertEqual(board.reveal(4, 4), set()) + self.assertIsNone(board.toggle_flag(4, 4)) + + def test_no_actions_after_win(self): + board = Board(4, 4, mines={(0, 0)}) + board.reveal(3, 3) # floods everything but the mine + self.assertTrue(board.is_won) + self.assertEqual(board.reveal(0, 0), set()) + self.assertIsNone(board.toggle_flag(0, 0)) + + def test_win_requires_all_safe_cells(self): + board = Board(4, 4, mines={(0, 0), (0, 1)}) + self.assertFalse(board.is_won) + for r in range(4): + for c in range(4): + if (r, c) not in board.mines: + board.reveal(r, c) + self.assertTrue(board.is_won) + + def test_reveal_outside_board_is_ignored(self): + board = Board(5, 5, mines={(0, 0)}) + self.assertEqual(board.reveal(-1, 0), set()) + self.assertEqual(board.reveal(5, 0), set()) + self.assertFalse(board.lost) + + +if __name__ == "__main__": + unittest.main()
@@ -0,0 +1,34 @@+"""Contract tests: every module in main.GAME_REGISTRY follows GAME_SPEC.md.""" +from __future__ import annotations + +import importlib +import unittest + +import main + + +class GameRegistryContractTest(unittest.TestCase): + def test_registered_modules_expose_the_spec_api(self): + self.assertTrue(main.GAME_REGISTRY, "registry must not be empty") + for module_name in main.GAME_REGISTRY: + module = importlib.import_module(module_name) + with self.subTest(module=module_name): + self.assertIsInstance(getattr(module, "NAME", None), str) + self.assertTrue(module.NAME.strip()) + self.assertIsInstance(getattr(module, "KEY", None), str) + self.assertTrue(module.KEY.strip()) + self.assertIsInstance(getattr(module, "DESCRIPTION", None), str) + self.assertTrue(module.DESCRIPTION.strip()) + game = getattr(module, "Game", None) + self.assertIsNotNone(game, "module must define class Game") + self.assertTrue(callable(getattr(game, "run", None)), + "Game must define a callable run()") + + def test_keys_are_unique(self): + keys = [importlib.import_module(m).KEY for m in main.GAME_REGISTRY] + self.assertEqual(len(keys), len(set(keys)), + f"duplicated KEYs: {sorted(keys)}") + + +if __name__ == "__main__": + unittest.main()
@@ -0,0 +1,271 @@+"""Headless unit tests for games.reversi (pure logic only).""" +from __future__ import annotations + +import unittest + +from games.reversi import ( + BLACK, + EMPTY, + SIZE, + WHITE, + Board, + ComputerPlayer, + GameState, + coordinate_name, + opponent, + parse_coordinate, +) + + +def empty_board() -> Board: + """A board with every square cleared, for custom setups.""" + board = Board() + board.grid = [[EMPTY] * SIZE for _ in range(SIZE)] + return board + + +class BoardSetupTests(unittest.TestCase): + def test_standard_starting_position(self): + board = Board() + self.assertEqual(board.get(3, 3), WHITE) + self.assertEqual(board.get(3, 4), BLACK) + self.assertEqual(board.get(4, 3), BLACK) + self.assertEqual(board.get(4, 4), WHITE) + + def test_initial_score_is_two_each(self): + counts = Board().count() + self.assertEqual(counts[BLACK], 2) + self.assertEqual(counts[WHITE], 2) + + def test_opponent(self): + self.assertEqual(opponent(BLACK), WHITE) + self.assertEqual(opponent(WHITE), BLACK) + + +class LegalMoveTests(unittest.TestCase): + def test_first_player_has_exactly_four_moves(self): + moves = Board().legal_moves(BLACK) + self.assertEqual(len(moves), 4) + self.assertEqual( + set(moves), {(2, 3), (3, 2), (4, 5), (5, 4)}) + + def test_second_player_has_exactly_four_moves(self): + self.assertEqual(len(Board().legal_moves(WHITE)), 4) + + def test_occupied_square_is_not_legal(self): + board = Board() + self.assertFalse(board.is_legal(BLACK, 3, 3)) + + def test_square_without_flank_is_not_legal(self): + board = Board() + self.assertFalse(board.is_legal(BLACK, 0, 0)) + self.assertEqual(board.flips_for(BLACK, 0, 0), []) + + def test_move_must_end_on_own_disc(self): + # A lone enemy disc with no own disc beyond it cannot be flanked. + board = empty_board() + board.grid[0][0] = WHITE + board.grid[0][1] = EMPTY + self.assertFalse(board.is_legal(BLACK, 0, 1)) + + def test_has_move_matches_legal_moves(self): + board = Board() + self.assertTrue(board.has_move(BLACK)) + cleared = empty_board() + cleared.grid[4][4] = WHITE # no black disc anywhere + self.assertFalse(cleared.has_move(BLACK)) + + +class FlipTests(unittest.TestCase): + def test_opening_move_flips_one_disc(self): + board = Board() + flipped = board.apply(BLACK, 2, 3) + self.assertEqual(flipped, [(3, 3)]) + self.assertEqual(board.get(2, 3), BLACK) + self.assertEqual(board.get(3, 3), BLACK) + counts = board.count() + self.assertEqual(counts[BLACK], 4) + self.assertEqual(counts[WHITE], 1) + + def test_illegal_move_raises_and_changes_nothing(self): + board = Board() + before = [row[:] for row in board.grid] + with self.assertRaises(ValueError): + board.apply(BLACK, 0, 0) + self.assertEqual(board.grid, before) + + def test_multi_direction_flip_all_eight_directions(self): + board = empty_board() + # Place white discs around (3, 3), each line anchored by black. + anchors = [(1, 1), (1, 3), (1, 5), (3, 1), (3, 5), + (5, 1), (5, 3), (5, 5)] + targets = [(2, 2), (2, 3), (2, 4), (3, 2), (3, 4), + (4, 2), (4, 3), (4, 4)] + for row, col in anchors: + board.grid[row][col] = BLACK + for row, col in targets: + board.grid[row][col] = WHITE + flipped = board.flips_for(BLACK, 3, 3) + self.assertEqual(set(flipped), set(targets)) + board.apply(BLACK, 3, 3) + for row, col in targets: + self.assertEqual(board.get(row, col), BLACK) + # Anchors were already black and are untouched. + for row, col in anchors: + self.assertEqual(board.get(row, col), BLACK) + + def test_long_line_flip(self): + board = empty_board() + for col in (1, 2, 3, 4, 5): + board.grid[0][col] = WHITE + board.grid[0][6] = BLACK + flipped = board.apply(BLACK, 0, 0) + self.assertEqual(set(flipped), {(0, c) for c in range(1, 6)}) + self.assertEqual(board.count()[BLACK], 7) + + def test_copy_is_independent(self): + board = Board() + clone = board.copy() + clone.apply(BLACK, 2, 3) + self.assertEqual(board.get(2, 3), EMPTY) + self.assertEqual(board.count()[BLACK], 2) + + +class PassLogicTests(unittest.TestCase): + def _pass_setup(self) -> GameState: + # Black to move, but only white has a legal move. + state = GameState() + state.board.grid = [[EMPTY] * SIZE for _ in range(SIZE)] + state.board.grid[0][0] = WHITE + state.board.grid[0][1] = BLACK + state.board.grid[1][0] = WHITE + state.board.grid[1][1] = BLACK + state.current = BLACK + return state + + def test_must_pass_when_no_legal_move(self): + state = self._pass_setup() + self.assertEqual(state.legal_moves(), []) + self.assertTrue(state.must_pass()) + self.assertFalse(state.game_over()) + + def test_pass_turn_hands_over_and_play_continues(self): + state = self._pass_setup() + state.pass_turn() + self.assertEqual(state.current, WHITE) + self.assertFalse(state.must_pass()) + # White can now flank either black disc, plus a diagonal. + self.assertEqual(set(state.legal_moves()), {(0, 2), (1, 2), (2, 2)}) + state.play(0, 2) + self.assertEqual(state.board.get(0, 1), WHITE) + + def test_normal_position_does_not_require_pass(self): + state = GameState() + self.assertFalse(state.must_pass()) + + +class GameOverTests(unittest.TestCase): + def test_fresh_game_is_not_over(self): + self.assertFalse(GameState().game_over()) + + def test_game_over_when_neither_player_can_move(self): + state = GameState() + # Only white discs and no possible flank for either side. + for row in range(SIZE): + for col in range(SIZE): + state.board.grid[row][col] = WHITE + state.board.grid[7][7] = EMPTY + state.board.grid[7][6] = BLACK + state.board.grid[7][5] = EMPTY + self.assertFalse(state.board.has_move(BLACK)) + self.assertFalse(state.board.has_move(WHITE)) + self.assertTrue(state.game_over()) + + def test_end_of_game_scoring_black_wins(self): + state = GameState() + for row in range(SIZE): + for col in range(SIZE): + state.board.grid[row][col] = BLACK + state.board.grid[0][0] = WHITE + state.board.grid[0][1] = WHITE + state.board.grid[0][2] = EMPTY + state.board.grid[0][3] = EMPTY + self.assertTrue(state.game_over()) + self.assertEqual(state.score(), (60, 2)) + self.assertEqual(state.winner(), BLACK) + + def test_end_of_game_draw(self): + state = GameState() + for row in range(SIZE // 2): + for col in range(SIZE): + state.board.grid[row][col] = BLACK + for row in range(SIZE // 2, SIZE): + for col in range(SIZE): + state.board.grid[row][col] = WHITE + self.assertTrue(state.game_over()) + self.assertEqual(state.score(), (32, 32)) + self.assertIsNone(state.winner()) + + def test_turn_alternates_after_play(self): + state = GameState() + state.play(2, 3) + self.assertEqual(state.current, WHITE) + state.play(2, 2) + self.assertEqual(state.current, BLACK) + + +class ComputerPlayerTests(unittest.TestCase): + def test_choose_move_returns_a_legal_move(self): + board = Board() + computer = ComputerPlayer(BLACK) + move = computer.choose_move(board) + self.assertIn(move, board.legal_moves(BLACK)) + + def test_choose_move_none_when_no_moves(self): + board = empty_board() + board.grid[0][0] = WHITE # black has no disc to anchor a flank + self.assertIsNone(ComputerPlayer(BLACK).choose_move(board)) + + def test_ai_prefers_corner(self): + board = empty_board() + board.grid[0][1] = WHITE + board.grid[0][2] = BLACK + board.grid[3][3] = WHITE + board.grid[3][4] = BLACK + move = ComputerPlayer(BLACK).choose_move(board) + self.assertEqual(move, (0, 0)) + + def test_ai_avoids_square_next_to_empty_corner(self): + board = empty_board() + board.grid[0][0] = EMPTY # corner still empty + # Two legal moves: (1, 1) next to the corner, (5, 5) far away. + board.grid[1][2] = WHITE + board.grid[1][3] = BLACK + board.grid[5][6] = WHITE + board.grid[5][7] = BLACK + computer = ComputerPlayer(BLACK) + move = computer.choose_move(board) + self.assertNotEqual(move, (1, 1)) + self.assertEqual(move, (5, 5)) + + +class CoordinateTests(unittest.TestCase): + def test_parse_valid_coordinate(self): + self.assertEqual(parse_coordinate("d3"), (2, 3)) + self.assertEqual(parse_coordinate("D3"), (2, 3)) + self.assertEqual(parse_coordinate(" a1 "), (0, 0)) + self.assertEqual(parse_coordinate("h8"), (7, 7)) + + def test_parse_rejects_garbage(self): + for bad in ("", "d", "d33", "3d", "dd", "i1", "a9", "a0", "q"): + self.assertIsNone(parse_coordinate(bad), bad) + + def test_coordinate_name_round_trip(self): + for row in range(SIZE): + for col in range(SIZE): + self.assertEqual( + parse_coordinate(coordinate_name(row, col)), (row, col)) + + +if __name__ == "__main__": + unittest.main()
@@ -0,0 +1,157 @@+"""Tests for core.storage.Store (JSON statistics persistence).""" +from __future__ import annotations + +import json +import tempfile +import unittest +from pathlib import Path + +from core.storage import Store + + +class StoreTestCase(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + self.path = Path(self._tmp.name) / "stats.json" + + def write(self, data) -> None: + if isinstance(data, str): + self.path.write_text(data, encoding="utf-8") + else: + self.path.write_text(json.dumps(data), encoding="utf-8") + + +class LoadTest(StoreTestCase): + def test_missing_file_starts_empty(self): + store = Store(self.path) + self.assertEqual(store.data, {"games": {}, "records": {}}) + + def test_valid_json_is_loaded(self): + self.write({"games": {"checkers": {"played": 3, "won": 2, + "lost": 1, "drawn": 0}}, + "records": {"2048_high_score": 1024}}) + store = Store(self.path) + self.assertEqual(store.game_stats("checkers"), + {"played": 3, "won": 2, "lost": 1, "drawn": 0}) + self.assertEqual(store.get_record("2048_high_score"), 1024) + + def test_corrupted_json_starts_empty(self): + self.write("{not valid json") + store = Store(self.path) + self.assertEqual(store.data, {"games": {}, "records": {}}) + + def test_non_dict_json_starts_empty(self): + self.write(["games", "records"]) + store = Store(self.path) + self.assertEqual(store.data, {"games": {}, "records": {}}) + + def test_missing_fields_default_to_zero(self): + self.write({"games": {"checkers": {"played": 5}}}) + store = Store(self.path) + self.assertEqual(store.game_stats("checkers"), + {"played": 5, "won": 0, "lost": 0, "drawn": 0}) + + def test_unexpected_fields_are_ignored(self): + self.write({"games": {"checkers": {"played": 5, "cheated": 99}}, + "extra_top_level": True}) + store = Store(self.path) + self.assertNotIn("cheated", store.game_stats("checkers")) + self.assertNotIn("extra_top_level", store.data) + + def test_non_int_fields_are_ignored(self): + self.write({"games": {"checkers": {"played": "five", "won": 2}}}) + store = Store(self.path) + self.assertEqual(store.game_stats("checkers"), + {"played": 0, "won": 2, "lost": 0, "drawn": 0}) + + def test_bool_fields_are_ignored(self): + # bool is a subclass of int in Python, but JSON true/false is + # not a statistic. + self.write({"games": {"checkers": {"played": True, "won": 2}}}) + store = Store(self.path) + self.assertEqual(store.game_stats("checkers"), + {"played": 0, "won": 2, "lost": 0, "drawn": 0}) + + def test_non_dict_game_entry_is_ignored(self): + self.write({"games": {"checkers": "oops"}}) + store = Store(self.path) + self.assertNotIn("checkers", store.data["games"]) + + def test_unknown_game_keys_are_kept(self): + # The store is game-agnostic: keys come from the game modules. + self.write({"games": {"some_future_game": {"played": 1}}}) + store = Store(self.path) + self.assertEqual(store.game_stats("some_future_game")["played"], 1) + + def test_non_dict_records_are_ignored(self): + self.write({"records": "oops"}) + store = Store(self.path) + self.assertEqual(store.data["records"], {}) + + +class UpdateTest(StoreTestCase): + def test_record_result_counts_each_outcome(self): + store = Store(self.path) + store.record_result("gomoku", "win") + store.record_result("gomoku", "loss") + store.record_result("gomoku", "draw") + self.assertEqual(store.game_stats("gomoku"), + {"played": 3, "won": 1, "lost": 1, "drawn": 1}) + + def test_record_result_rejects_unknown_outcome(self): + store = Store(self.path) + store.record_result("gomoku", "cheated") + self.assertEqual(store.game_stats("gomoku"), {}) + + def test_record_result_preserves_existing_data(self): + self.write({"games": {"gomoku": {"played": 7, "won": 4, + "lost": 2, "drawn": 1}}}) + store = Store(self.path) + store.record_result("gomoku", "win") + self.assertEqual(store.game_stats("gomoku"), + {"played": 8, "won": 5, "lost": 2, "drawn": 1}) + + def test_improve_record_higher_is_better(self): + store = Store(self.path) + self.assertTrue(store.improve_record("best", 100)) + self.assertFalse(store.improve_record("best", 50)) + self.assertTrue(store.improve_record("best", 150)) + self.assertEqual(store.get_record("best"), 150) + + def test_improve_record_lower_is_better(self): + store = Store(self.path) + self.assertTrue(store.improve_record("time", 30, lower_is_better=True)) + self.assertFalse(store.improve_record("time", 45, lower_is_better=True)) + self.assertTrue(store.improve_record("time", 20, lower_is_better=True)) + self.assertEqual(store.get_record("time"), 20) + + +class PersistenceTest(StoreTestCase): + def test_save_persists_and_reloads(self): + store = Store(self.path) + store.record_result("mancala", "win") + store.set_record("fifteen_best_moves", 42) + reloaded = Store(self.path) + self.assertEqual(reloaded.game_stats("mancala"), + {"played": 1, "won": 1, "lost": 0, "drawn": 0}) + self.assertEqual(reloaded.get_record("fifteen_best_moves"), 42) + + def test_saved_file_is_valid_json(self): + store = Store(self.path) + store.record_result("fifteen", "win") + data = json.loads(self.path.read_text(encoding="utf-8")) + self.assertEqual(data["games"]["fifteen"]["won"], 1) + + def test_save_leaves_no_temp_file_behind(self): + store = Store(self.path) + store.save() + self.assertFalse((self.path.parent / "stats.tmp").exists()) + + def test_save_never_raises_on_io_error(self): + store = Store(self.path.parent) # a directory: writing fails + store.record_result("gomoku", "win") # must not raise + + +if __name__ == "__main__": + unittest.main()
@@ -0,0 +1,164 @@+"""Headless unit tests for games.tic_tac_toe pure logic. + +These tests never touch the real terminal: they drive Board, +parse_coordinate and choose_computer_move directly. +""" +from __future__ import annotations + +import unittest + +from games.tic_tac_toe import ( + Board, + EMPTY, + MARK_O, + MARK_X, + SIZE, + choose_computer_move, + parse_coordinate, +) + + +class TestParseCoordinate(unittest.TestCase): + def test_valid_coordinates(self): + self.assertEqual(parse_coordinate("a1"), (0, 0)) + self.assertEqual(parse_coordinate("b2"), (1, 1)) + self.assertEqual(parse_coordinate("c3"), (2, 2)) + + def test_case_insensitive_and_whitespace(self): + self.assertEqual(parse_coordinate("B2"), (1, 1)) + self.assertEqual(parse_coordinate(" C1 "), (0, 2)) + + def test_invalid_coordinates(self): + for bad in ("", "a", "a1b", "d1", "a4", "1a", "b0", "xy", "!!"): + self.assertIsNone(parse_coordinate(bad), bad) + + +class TestBoardBasics(unittest.TestCase): + def test_place_and_get(self): + board = Board() + self.assertTrue(board.place(MARK_X, 1, 1)) + self.assertEqual(board.get(1, 1), MARK_X) + self.assertFalse(board.is_empty(1, 1)) + + def test_place_on_occupied_square_fails(self): + board = Board() + board.place(MARK_X, 0, 0) + self.assertFalse(board.place(MARK_O, 0, 0)) + self.assertEqual(board.get(0, 0), MARK_X) + + def test_full_board_detection(self): + board = Board() + self.assertFalse(board.is_full()) + for r in range(SIZE): + for c in range(SIZE): + board.place(MARK_X, r, c) + self.assertTrue(board.is_full()) + self.assertEqual(board.empty_cells(), []) + + +def _fill(board, moves): + """Play (mark, row, col) triples, asserting each move is legal.""" + for mark, row, col in moves: + assert board.place(mark, row, col), (mark, row, col) + + +class TestWinDetection(unittest.TestCase): + def test_horizontal_win(self): + for row in range(SIZE): + board = Board() + _fill(board, [(MARK_X, row, 0), (MARK_X, row, 1), (MARK_X, row, 2)]) + self.assertEqual(board.winner(), MARK_X) + self.assertTrue(board.is_game_over()) + line, mark = board.winning_line() + self.assertEqual(mark, MARK_X) + self.assertEqual(set(line), {(row, 0), (row, 1), (row, 2)}) + + def test_vertical_win(self): + for col in range(SIZE): + board = Board() + _fill(board, [(MARK_O, 0, col), (MARK_O, 1, col), (MARK_O, 2, col)]) + self.assertEqual(board.winner(), MARK_O) + self.assertTrue(board.is_game_over()) + + def test_diagonal_win(self): + board = Board() + _fill(board, [(MARK_X, 0, 0), (MARK_X, 1, 1), (MARK_X, 2, 2)]) + self.assertEqual(board.winner(), MARK_X) + + def test_anti_diagonal_win(self): + board = Board() + _fill(board, [(MARK_O, 0, 2), (MARK_O, 1, 1), (MARK_O, 2, 0)]) + self.assertEqual(board.winner(), MARK_O) + + def test_draw_detection(self): + # X O X + # X X O + # O X O -> full board, no winner + board = Board() + _fill(board, [ + (MARK_X, 0, 0), (MARK_O, 0, 1), (MARK_X, 0, 2), + (MARK_X, 1, 0), (MARK_X, 1, 1), (MARK_O, 1, 2), + (MARK_O, 2, 0), (MARK_X, 2, 1), (MARK_O, 2, 2), + ]) + self.assertTrue(board.is_full()) + self.assertIsNone(board.winner()) + self.assertTrue(board.is_game_over()) + + def test_no_false_positive(self): + board = Board() + _fill(board, [(MARK_X, 0, 0), (MARK_O, 0, 1)]) + self.assertIsNone(board.winner()) + self.assertFalse(board.is_game_over()) + + +class TestComputerMove(unittest.TestCase): + def test_takes_winning_move(self): + # O O . + # X X . + # . . . -> O completes row 0 at (0, 2) + board = Board() + _fill(board, [ + (MARK_O, 0, 0), (MARK_O, 0, 1), + (MARK_X, 1, 0), (MARK_X, 1, 1), + ]) + self.assertEqual(choose_computer_move(board, MARK_O), (0, 2)) + + def test_blocks_opponent_win(self): + # X X . + # . O . + # . . . -> O must block at (0, 2) + board = Board() + _fill(board, [ + (MARK_X, 0, 0), (MARK_X, 0, 1), + (MARK_O, 1, 1), + ]) + self.assertEqual(choose_computer_move(board, MARK_O), (0, 2)) + + def test_prefers_center(self): + board = Board() + self.assertEqual(choose_computer_move(board, MARK_X), (1, 1)) + + def test_prefers_corner_when_center_taken(self): + board = Board() + board.place(MARK_X, 1, 1) + row, col = choose_computer_move(board, MARK_O) + self.assertIn(row, (0, 2)) + self.assertIn(col, (0, 2)) + + def test_move_is_always_legal(self): + board = Board() + _fill(board, [(MARK_X, 0, 0), (MARK_O, 1, 1), (MARK_X, 2, 2)]) + for _ in range(20): + row, col = choose_computer_move(board, MARK_O) + self.assertEqual(board.get(row, col), EMPTY) + + def test_full_board_returns_none(self): + board = Board() + for r in range(SIZE): + for c in range(SIZE): + board.place(MARK_X, r, c) + self.assertIsNone(choose_computer_move(board, MARK_O)) + + +if __name__ == "__main__": + unittest.main()