From c60c9ef013589c84f6a0f7601aa37f2da48e9de5 Mon Sep 17 00:00:00 2001 From: Fabro Date: Thu, 4 Jun 2026 19:50:12 +0000 Subject: [PATCH] fabro(01KT9YT14FHDYA4VTV9DSFG0FY): impl_ui (succeeded) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fabro-Run: 01KT9YT14FHDYA4VTV9DSFG0FY Fabro-Completed: 12 Fabro-Checkpoint: 09dcef26f069e7c4ea20aaf2a84239aa193b76e2 ⚒️ Generated with [Fabro](https://fabro.sh) --- card-game-app/main.py | 4 - card-game-app/src/card_game_tui/render.py | 47 +++ card-game-app/src/card_game_tui/tui.py | 425 +++++++++++++++++++++- card-game-app/tests/test_render.py | 29 ++ card-game-app/tests/test_tui.py | 73 ++++ 5 files changed, 571 insertions(+), 7 deletions(-) create mode 100644 card-game-app/src/card_game_tui/render.py create mode 100644 card-game-app/tests/test_render.py create mode 100644 card-game-app/tests/test_tui.py diff --git a/card-game-app/main.py b/card-game-app/main.py index 2fe159c3a..541502ad6 100644 --- a/card-game-app/main.py +++ b/card-game-app/main.py @@ -45,10 +45,6 @@ def main(): if "--smoke" in sys.argv: sys.exit(run_smoke_test()) else: - print("FreeCell Solitaire TUI") - print("To run the smoke test, execute with --smoke") - print("Starting interactive UI (Placeholder)...") - # In future milestones, this will invoke the curses UI from card_game_tui.tui import TUIApp app = TUIApp() app.run() diff --git a/card-game-app/src/card_game_tui/render.py b/card-game-app/src/card_game_tui/render.py new file mode 100644 index 000000000..025d35524 --- /dev/null +++ b/card-game-app/src/card_game_tui/render.py @@ -0,0 +1,47 @@ +from typing import Union +from card_game_tui.domain import Card, Suit + +def get_card_inner(card: Card) -> str: + """ + Returns the exact 3-character representation of a card's rank and suit symbol. + """ + label = card.rank.label + symbol = card.suit.symbol + return f"{label:>2}{symbol}" + +def get_empty_foundation_inner(suit_idx: int) -> str: + """ + Returns the suit symbol with padding for an empty foundation slot. + """ + suits = list(Suit) + if 0 <= suit_idx < len(suits): + return f" {suits[suit_idx].symbol} " + return " " + +def get_card_lines(card: Union[Card, None], is_empty: bool = False, empty_suit_idx: int = -1) -> list[str]: + """ + Returns 3 lines representing a card or empty cell. + """ + if is_empty: + if empty_suit_idx >= 0: + inner = get_empty_foundation_inner(empty_suit_idx) + else: + inner = " " + return [ + "+---+", + f"|{inner}|", + "+---+" + ] + else: + if card is None: + return [ + "+---+", + "| |", + "+---+" + ] + inner = get_card_inner(card) + return [ + "+---+", + f"|{inner}|", + "+---+" + ] diff --git a/card-game-app/src/card_game_tui/tui.py b/card-game-app/src/card_game_tui/tui.py index 5bb460b18..1119eaa6e 100644 --- a/card-game-app/src/card_game_tui/tui.py +++ b/card-game-app/src/card_game_tui/tui.py @@ -1,8 +1,427 @@ -# TUI module placeholder for FreeCell Solitaire TUI +import curses +import random +import sys +from typing import Union, List, Optional +from card_game_tui.domain import GameState, Position, LocationType, Suit, Card +from card_game_tui.render import get_card_inner, get_empty_foundation_inner class TUIApp: def __init__(self): - pass + # We generate a random seed for the game to start with, e.g. 1 to 99999 + self.current_seed = random.randint(1, 99999) + self.state = GameState(seed=self.current_seed) + + # Cursor and Selection State + self.cursor_row = 1 # 0: Top row (Freecells/Foundations), 1: Tableau Columns + self.cursor_col = 0 # 0 to 7 + + self.selected_pos: Optional[Position] = None + self.selected_count = 1 + + self.message = "Welcome to FreeCell! Select a card to begin." + self.message_attr = 0 # Will be set to color pair + + self.quit_confirm = False + + def reset_game(self, seed: int): + self.current_seed = seed + self.state = GameState(seed=seed) + self.selected_pos = None + self.selected_count = 1 + self.cursor_row = 1 + self.cursor_col = 0 + self.message = f"Game started with seed: {self.current_seed}" + self.message_attr = 0 + self.quit_confirm = False + + def get_current_pos(self) -> Position: + if self.cursor_row == 0: + if self.cursor_col < 4: + return Position(LocationType.FREECELL, self.cursor_col) + else: + return Position(LocationType.FOUNDATION, self.cursor_col - 4) + else: + return Position(LocationType.TABLEAU, self.cursor_col) + + def find_max_valid_count(self, from_pos: Position, to_pos: Position) -> int: + if from_pos.type != LocationType.TABLEAU: + return 1 + col = self.state.tableaus[from_pos.index] + if not col: + return 0 + + # Find maximum length of alternating, descending sequence at bottom + max_seq = 1 + for i in range(len(col) - 2, -1, -1): + c1 = col[i] + c2 = col[i+1] + if c2.rank.value == c1.rank.value - 1 and c2.suit.color != c1.suit.color: + max_seq += 1 + else: + break + + # Find the maximum count that is legally movable to destination + for count in range(max_seq, 0, -1): + if self.state.validate_move(from_pos, to_pos, count): + return count + return 0 + + def find_longest_sequence(self, col_idx: int) -> int: + col = self.state.tableaus[col_idx] + if not col: + return 0 + max_seq = 1 + for i in range(len(col) - 2, -1, -1): + c1 = col[i] + c2 = col[i+1] + if c2.rank.value == c1.rank.value - 1 and c2.suit.color != c1.suit.color: + max_seq += 1 + else: + break + return max_seq + + def safe_addstr(self, stdscr, y: int, x: int, text: str, attr: int = 0): + try: + if y < self.max_y and x < self.max_x: + available = self.max_x - x + if len(text) > available: + text = text[:available] + stdscr.addstr(y, x, text, attr) + except curses.error: + pass + + def draw_board(self, stdscr): + # Clear screen + stdscr.erase() + + # Draw Title + title_attr = curses.color_pair(3) | curses.A_BOLD + self.safe_addstr(stdscr, 1, 2, "FREECELL SOLITAIRE", title_attr) + self.safe_addstr(stdscr, 1, 55, f"[SEED: {self.current_seed}]", curses.color_pair(4)) + + # Labels + label_attr = curses.color_pair(4) | curses.A_BOLD + self.safe_addstr(stdscr, 3, 2, "Free Cells", label_attr) + self.safe_addstr(stdscr, 3, 34, "Foundations", label_attr) + + # Free Cells (Cols 0-3 on row 0) + for i in range(4): + x = 2 + i * 8 + y = 4 + is_cursor = (self.cursor_row == 0 and self.cursor_col == i) + + # Draw header label [A], [B], [C], [D] + header_attr = (curses.color_pair(3) | curses.A_BOLD) if is_cursor else curses.A_NORMAL + self.safe_addstr(stdscr, y - 1, x, f" [{chr(65 + i)}] ", header_attr) + + card = self.state.freecells[i] + border_attr = curses.color_pair(3) if is_cursor else curses.A_NORMAL + is_selected = (self.selected_pos is not None and self.selected_pos.type == LocationType.FREECELL and self.selected_pos.index == i) + + if card is None: + self.safe_addstr(stdscr, y, x, "+---+", border_attr) + self.safe_addstr(stdscr, y + 1, x, "| |", border_attr) + self.safe_addstr(stdscr, y + 2, x, "+---+", border_attr) + else: + card_attr = curses.color_pair(1) if card.suit.color == "RED" else curses.color_pair(2) + if is_selected: + card_attr |= curses.A_REVERSE + self.safe_addstr(stdscr, y, x, "+---+", border_attr) + self.safe_addstr(stdscr, y + 1, x, f"|{get_card_inner(card)}|", card_attr) + self.safe_addstr(stdscr, y + 2, x, "+---+", border_attr) + + # Foundations (Cols 4-7 on row 0) + for i in range(4): + x = 34 + i * 8 + y = 4 + is_cursor = (self.cursor_row == 0 and self.cursor_col == i + 4) + + suit = list(Suit)[i] + header_attr = (curses.color_pair(3) | curses.A_BOLD) if is_cursor else curses.A_NORMAL + self.safe_addstr(stdscr, y - 1, x, f" [{suit.symbol}] ", header_attr) + + pile = self.state.foundations[suit] + border_attr = curses.color_pair(3) if is_cursor else curses.A_NORMAL + + if not pile: + inner = get_empty_foundation_inner(i) + inner_attr = curses.color_pair(1) if suit.color == "RED" else curses.color_pair(2) + inner_attr |= curses.A_DIM + self.safe_addstr(stdscr, y, x, "+---+", border_attr) + self.safe_addstr(stdscr, y + 1, x, f"|{inner}|", inner_attr) + self.safe_addstr(stdscr, y + 2, x, "+---+", border_attr) + else: + card = pile[-1] + card_attr = curses.color_pair(1) if card.suit.color == "RED" else curses.color_pair(2) + self.safe_addstr(stdscr, y, x, "+---+", border_attr) + self.safe_addstr(stdscr, y + 1, x, f"|{get_card_inner(card)}|", card_attr) + self.safe_addstr(stdscr, y + 2, x, "+---+", border_attr) + + # Tableau columns + self.safe_addstr(stdscr, 8, 2, "Tableau Columns", label_attr) + + for col_idx in range(8): + x = 2 + col_idx * 8 + y = 10 + is_cursor_col = (self.cursor_row == 1 and self.cursor_col == col_idx) + + # Header label [1] to [8] + header_attr = (curses.color_pair(3) | curses.A_BOLD) if is_cursor_col else curses.A_NORMAL + self.safe_addstr(stdscr, y - 1, x, f" [{col_idx + 1}] ", header_attr) + + col = self.state.tableaus[col_idx] + + if not col: + empty_attr = curses.color_pair(3) if is_cursor_col else curses.A_DIM + self.safe_addstr(stdscr, y, x, "+---+", empty_attr) + self.safe_addstr(stdscr, y + 1, x, "| |", empty_attr) + self.safe_addstr(stdscr, y + 2, x, "+---+", empty_attr) + else: + # Draw stacked cards + top_border_attr = curses.color_pair(3) if is_cursor_col else curses.A_NORMAL + self.safe_addstr(stdscr, y, x, "+---+", top_border_attr) + + for card_idx, card in enumerate(col): + is_selected_card = False + if (self.selected_pos is not None and + self.selected_pos.type == LocationType.TABLEAU and + self.selected_pos.index == col_idx): + if card_idx >= len(col) - self.selected_count: + is_selected_card = True + + card_attr = curses.color_pair(1) if card.suit.color == "RED" else curses.color_pair(2) + if is_selected_card: + card_attr |= curses.A_REVERSE + + card_y = y + 1 + card_idx + self.safe_addstr(stdscr, card_y, x, f"|{get_card_inner(card)}|", card_attr) + + bottom_y = y + 1 + len(col) + bottom_border_attr = curses.color_pair(3) if is_cursor_col else curses.A_NORMAL + self.safe_addstr(stdscr, bottom_y, x, "+---+", bottom_border_attr) + + # Status & Message bar (fixed near bottom of standard screen size or max_y) + msg_y = max(20, self.max_y - 4) + sep_y = msg_y - 1 + keys_y = msg_y + 1 + + # Horizontal separator + self.safe_addstr(stdscr, sep_y, 0, "-" * self.max_x, curses.A_DIM) + + # Message string + self.safe_addstr(stdscr, msg_y, 2, f"MSG: {self.message}", self.message_attr) + + # Keys bar + keys_text = "KEYS: [Arrows/WASD] Navigate [Space/Enter] Select/Drop [U] Undo [Y] Redo [R] Restart [N] New [Q] Quit" + if self.selected_pos is not None: + longest_seq = self.find_longest_sequence(self.selected_pos.index) if self.selected_pos.type == LocationType.TABLEAU else 1 + if longest_seq > 1: + keys_text = f"KEYS: [-/+] Move Count: {self.selected_count}/{longest_seq} [Arrows/WASD] Navigate [Space] Drop [Esc] Cancel" + + self.safe_addstr(stdscr, keys_y, 2, keys_text, curses.color_pair(4) | curses.A_BOLD) + + def handle_input(self, key: int) -> bool: + """ + Processes key inputs. Returns True if we should keep running, False if we should exit. + """ + # If in Quit Confirmation state + if self.quit_confirm: + if key in (ord('y'), ord('Y')): + return False + else: + self.quit_confirm = False + self.message = "Quit canceled. Game resumed." + self.message_attr = curses.color_pair(4) + return True + + # Standard navigation keys + # UP: Arrow UP, w, W, k, K + if key in (curses.KEY_UP, ord('w'), ord('W'), ord('k'), ord('K')): + self.cursor_row = 0 + self.message_attr = 0 + # DOWN: Arrow DOWN, s, S, j, J + elif key in (curses.KEY_DOWN, ord('s'), ord('S'), ord('j'), ord('J')): + self.cursor_row = 1 + self.message_attr = 0 + # LEFT: Arrow LEFT, a, A, h, H + elif key in (curses.KEY_LEFT, ord('a'), ord('A'), ord('h'), ord('H')): + self.cursor_col = (self.cursor_col - 1) % 8 + self.message_attr = 0 + # RIGHT: Arrow RIGHT, d, D, l, L + elif key in (curses.KEY_RIGHT, ord('d'), ord('D'), ord('l'), ord('L')): + self.cursor_col = (self.cursor_col + 1) % 8 + self.message_attr = 0 + + # Undo key: u, U + elif key in (ord('u'), ord('U')): + if self.state.undo(): + self.message = "Undo successful." + self.message_attr = curses.color_pair(5) + self.selected_pos = None + else: + self.message = "Nothing to undo!" + self.message_attr = curses.color_pair(1) + + # Redo key: y, Y or Ctrl-Y (25) or Ctrl-R (18) + elif key in (ord('y'), ord('Y'), 25, 18): + if self.state.redo(): + self.message = "Redo successful." + self.message_attr = curses.color_pair(5) + self.selected_pos = None + else: + self.message = "Nothing to redo!" + self.message_attr = curses.color_pair(1) + + # Restart key: r, R + elif key in (ord('r'), ord('R')): + self.reset_game(self.current_seed) + self.message = f"Game restarted. Seed: {self.current_seed}" + self.message_attr = curses.color_pair(4) + + # New Game key: n, N + elif key in (ord('n'), ord('N')): + new_seed = random.randint(1, 99999) + self.reset_game(new_seed) + + # Quit key: q, Q + elif key in (ord('q'), ord('Q')): + self.quit_confirm = True + self.message = "Are you sure you want to quit? (y/n)" + self.message_attr = curses.color_pair(1) | curses.A_BOLD + + # Cancel Selection: Escape key (27) + elif key == 27: + if self.selected_pos is not None: + self.selected_pos = None + self.selected_count = 1 + self.message = "Selection canceled." + self.message_attr = curses.color_pair(4) + + # Selection Count adjustments (only valid when a Tableau selection is active) + elif key in (ord('-'), ord('_'), ord('[')) and self.selected_pos is not None and self.selected_pos.type == LocationType.TABLEAU: + if self.selected_count > 1: + self.selected_count -= 1 + self.message = f"Adjusted selection count to {self.selected_count}." + self.message_attr = curses.color_pair(4) + + elif key in (ord('+'), ord('='), ord(']')) and self.selected_pos is not None and self.selected_pos.type == LocationType.TABLEAU: + longest_seq = self.find_longest_sequence(self.selected_pos.index) + if self.selected_count < longest_seq: + self.selected_count += 1 + self.message = f"Adjusted selection count to {self.selected_count}." + self.message_attr = curses.color_pair(4) + + # Space (32) or Enter (10, 13, curses.KEY_ENTER) + elif key in (32, 10, 13, curses.KEY_ENTER): + curr_pos = self.get_current_pos() + + if self.selected_pos is None: + # Attempt to select + if curr_pos.type == LocationType.FOUNDATION: + self.message = "Cannot pick up cards from the foundation piles." + self.message_attr = curses.color_pair(1) + else: + card = self.state.get_card_at(curr_pos) + if card is None: + self.message = "That slot is empty!" + self.message_attr = curses.color_pair(1) + else: + self.selected_pos = curr_pos + # Default count to 1 + self.selected_count = 1 + + # Automatically compute max valid count if destination is a Tableau + # We will keep count as 1, but user can adjust or we automatically resolve on execute. + self.message = f"Selected {card}. Use [-/+] to change count. Select destination and press Space." + self.message_attr = curses.color_pair(4) + else: + # Destination select / execute move + if curr_pos == self.selected_pos: + # Clicking source again deselects + self.selected_pos = None + self.selected_count = 1 + self.message = "Selection cleared." + self.message_attr = curses.color_pair(4) + else: + # Let's perform move execution + # First, if moving Tableau-to-Tableau, let's automatically check if a sequence move is valid + # and if we should adjust selected_count if user didn't specify one larger than 1. + count_to_use = self.selected_count + if self.selected_pos.type == LocationType.TABLEAU and curr_pos.type == LocationType.TABLEAU: + # If user is moving a sequence and selected_count is 1, let's find if a larger sequence is valid and use it. + # This auto-moves the sequence by default if they didn't manually change count, matching standard games. + max_valid = self.find_max_valid_count(self.selected_pos, curr_pos) + if max_valid > count_to_use: + count_to_use = max_valid + + if self.state.execute_move(self.selected_pos, curr_pos, count=count_to_use): + self.message = "Move successful!" + self.message_attr = curses.color_pair(5) + + # Check win condition + if self.state.check_win(): + self.message = "CONGRATULATIONS! YOU HAVE WON THE GAME! Press N for new game, Q to quit." + self.message_attr = curses.color_pair(5) | curses.A_BLINK | curses.A_BOLD + # Check loss condition + elif self.state.check_loss(): + self.message = "GAME OVER! No legal moves remaining. Press R to restart, N for new game, Q to quit." + self.message_attr = curses.color_pair(1) | curses.A_BOLD + + self.selected_pos = None + self.selected_count = 1 + else: + # Show precise validation error + self.message = "Invalid move under FreeCell rules!" + self.message_attr = curses.color_pair(1) + # We do NOT clear selection on invalid move, so user can try another target! + + return True + + def _main_loop(self, stdscr) -> None: + # Curses configurations + curses.curs_set(0) # Hide hardware cursor + stdscr.keypad(True) # Enable arrow keys + stdscr.nodelay(False) # Wait for keys + + # Set up color pairs + if curses.has_colors(): + curses.start_color() + curses.use_default_colors() + # Pair 1: Red cards + curses.init_pair(1, curses.COLOR_RED, curses.COLOR_BLACK) + # Pair 2: Black cards + curses.init_pair(2, curses.COLOR_WHITE, curses.COLOR_BLACK) + # Pair 3: Headers / Active Selection + curses.init_pair(3, curses.COLOR_CYAN, curses.COLOR_BLACK) + # Pair 4: Keys / Info + curses.init_pair(4, curses.COLOR_YELLOW, curses.COLOR_BLACK) + # Pair 5: Success / Win + curses.init_pair(5, curses.COLOR_GREEN, curses.COLOR_BLACK) + + while True: + # Get current window boundaries + self.max_y, self.max_x = stdscr.getmaxyx() + + # Draw the game board + self.draw_board(stdscr) + + # Perform screen updates + stdscr.refresh() + + # Read next keyboard input + try: + key = stdscr.getch() + except KeyboardInterrupt: + break + + # Handle the keyboard input + if not self.handle_input(key): + break def run(self) -> None: - print("TUI App placeholder running.") + try: + curses.wrapper(self._main_loop) + except Exception as e: + # Restore terminal cleanly on error + print(f"Error in curses TUI: {e}", file=sys.stderr) + sys.exit(1) diff --git a/card-game-app/tests/test_render.py b/card-game-app/tests/test_render.py new file mode 100644 index 000000000..d08d868c1 --- /dev/null +++ b/card-game-app/tests/test_render.py @@ -0,0 +1,29 @@ +from card_game_tui.domain import Card, Suit, Rank +from card_game_tui.render import get_card_inner, get_empty_foundation_inner, get_card_lines + +def test_get_card_inner(): + # Test single-char rank label + card_ah = Card(Suit.HEARTS, Rank.ACE) + assert get_card_inner(card_ah) == " A♥" + + # Test double-char rank label + card_10s = Card(Suit.SPADES, Rank.TEN) + assert get_card_inner(card_10s) == "10♠" + + # Test face card + card_kd = Card(Suit.DIAMONDS, Rank.KING) + assert get_card_inner(card_kd) == " K♦" + +def test_get_empty_foundation_inner(): + assert get_empty_foundation_inner(0) == " ♥ " + assert get_empty_foundation_inner(1) == " ♦ " + assert get_empty_foundation_inner(2) == " ♣ " + assert get_empty_foundation_inner(3) == " ♠ " + assert get_empty_foundation_inner(4) == " " + +def test_get_card_lines(): + card_ah = Card(Suit.HEARTS, Rank.ACE) + assert get_card_lines(card_ah) == ["+---+", "| A♥|", "+---+"] + assert get_card_lines(None) == ["+---+", "| |", "+---+"] + assert get_card_lines(None, is_empty=True, empty_suit_idx=0) == ["+---+", "| ♥ |", "+---+"] + assert get_card_lines(None, is_empty=True, empty_suit_idx=-1) == ["+---+", "| |", "+---+"] diff --git a/card-game-app/tests/test_tui.py b/card-game-app/tests/test_tui.py new file mode 100644 index 000000000..72136f971 --- /dev/null +++ b/card-game-app/tests/test_tui.py @@ -0,0 +1,73 @@ +from card_game_tui.tui import TUIApp +from card_game_tui.domain import Position, LocationType, Card, Suit, Rank + +def test_tui_initialization(): + app = TUIApp() + assert app.cursor_row == 1 + assert app.cursor_col == 0 + assert app.selected_pos is None + assert app.selected_count == 1 + assert app.quit_confirm is False + +def test_tui_reset_game(): + app = TUIApp() + app.reset_game(12345) + assert app.current_seed == 12345 + assert app.selected_pos is None + assert app.cursor_row == 1 + assert app.cursor_col == 0 + +def test_tui_get_current_pos(): + app = TUIApp() + # Row 1, Col 3 should be Tableau index 3 + app.cursor_row = 1 + app.cursor_col = 3 + pos = app.get_current_pos() + assert pos.type == LocationType.TABLEAU + assert pos.index == 3 + + # Row 0, Col 2 should be FreeCell 2 + app.cursor_row = 0 + app.cursor_col = 2 + pos = app.get_current_pos() + assert pos.type == LocationType.FREECELL + assert pos.index == 2 + + # Row 0, Col 5 should be Foundation 1 (5 - 4) + app.cursor_row = 0 + app.cursor_col = 5 + pos = app.get_current_pos() + assert pos.type == LocationType.FOUNDATION + assert pos.index == 1 + +def test_tui_find_longest_sequence(): + app = TUIApp() + app.state.tableaus = [[] for _ in range(8)] + + # Empty column + assert app.find_longest_sequence(0) == 0 + + # Non-empty column + # Red 10, Black 9, Red 8 (descending and alternating) + c10 = Card(Suit.HEARTS, Rank.TEN) + c9 = Card(Suit.SPADES, Rank.NINE) + c8 = Card(Suit.DIAMONDS, Rank.EIGHT) + app.state.tableaus[0] = [Card(Suit.CLUBS, Rank.KING), c10, c9, c8] + assert app.find_longest_sequence(0) == 3 + +def test_tui_find_max_valid_count(): + app = TUIApp() + app.state.tableaus = [[] for _ in range(8)] + app.state.freecells = [None] * 4 + + # From Empty Tableau + from_pos = Position(LocationType.TABLEAU, 0) + to_pos = Position(LocationType.TABLEAU, 1) + assert app.find_max_valid_count(from_pos, to_pos) == 0 + + # With some sequence + c10 = Card(Suit.HEARTS, Rank.TEN) + c9 = Card(Suit.SPADES, Rank.NINE) + app.state.tableaus[0] = [c10, c9] + # Destination is empty column, max possible sequence to move is 2 + assert app.find_max_valid_count(from_pos, to_pos) == 2