mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-06 02:48:25 +00:00
fabro(01KTAFMYJW6ATWQB4KFNSNW37T): plan_app (succeeded)
Fabro-Run: 01KTAFMYJW6ATWQB4KFNSNW37T
Fabro-Completed: 2
Fabro-Checkpoint: b79ae37b2a
⚒️ Generated with [Fabro](https://fabro.sh)
This commit is contained in:
parent
8500dfa22c
commit
ff1fdd533b
5 changed files with 1098 additions and 0 deletions
155
.ai/card-game-fast-plan.md
Normal file
155
.ai/card-game-fast-plan.md
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
# Spider Solitaire Terminal Game Implementation Plan
|
||||
|
||||
This plan outlines the architecture, rules, UI design, and testing strategy for a terminal-based Spider Solitaire game built with Python and the standard `curses` library. All source files will be located in the `card-game-app/` directory.
|
||||
|
||||
---
|
||||
|
||||
## 1. Game Rules & Logic (Spider Solitaire)
|
||||
|
||||
### Card & Deck Representation
|
||||
- **Standard Spider Solitaire** uses **2 decks (104 cards)**.
|
||||
- **Difficulty / Suit Configurations**:
|
||||
- **1 Suit (Easy)**: All cards are Spades (♠). (104 Spades)
|
||||
- **2 Suits (Medium)**: Spades (♠) and Hearts (♥). (52 Spades, 52 Hearts)
|
||||
- **4 Suits (Hard)**: Spades (♠), Hearts (♥), Diamonds (♦), and Clubs (♣). (26 of each)
|
||||
- **Ranks**: King (K), Queen (Q), Jack (J), 10, 9, 8, 7, 6, 5, 4, 3, 2, Ace (A).
|
||||
|
||||
### Initial Deal / Tableau Setup
|
||||
- **10 Tableau Columns**:
|
||||
- Columns 1-4: 6 cards each (5 face-down, 1 face-up at the bottom).
|
||||
- Columns 5-10: 5 cards each (4 face-down, 1 face-up at the bottom).
|
||||
- Total dealt initially: 54 cards.
|
||||
- **Stock Pile**:
|
||||
- Remaining 50 cards are kept in the stock.
|
||||
- Dealt in 5 rounds of 10 cards each (1 card to each column).
|
||||
- **Constraint**: Dealing from the stock is only allowed if **no column is empty** (standard rule, though some variants allow dealing with empty columns; we will enforce standard rules or make it configurable).
|
||||
|
||||
### Card Movement Rules
|
||||
- **Moving a Card or Sequence**:
|
||||
- Any single face-up card can be moved to another column if the destination card's rank is exactly **one higher** than the card being moved. Suit does not matter for single card moves. (e.g., Any Jack can be placed on any Queen).
|
||||
- A sequence of cards can be moved *together* only if:
|
||||
1. They are in descending rank order (e.g., J, 10, 9, 8).
|
||||
2. They are of the **same suit** (e.g., all Spades).
|
||||
- Any face-up card or valid sequence can be moved to an **empty column**.
|
||||
- **Revealing Cards**:
|
||||
- If a move leaves a facedown card at the bottom of a column, that card is automatically flipped face-up.
|
||||
|
||||
### Clearing Sequences (Win Condition)
|
||||
- When a complete sequence of King down to Ace (K, Q, J, 10, 9, 8, 7, 6, 5, 4, 3, 2, A) of the **same suit** is formed in a column, it is automatically removed from the Tableau and placed in the Completed pile.
|
||||
- **Game Win**: When all 8 completed sequences (104 cards) are removed.
|
||||
- **Game Loss**: No more valid moves, the stock is empty, and the board is in a locked/unplayable state. (Usually, the user decides to resign, but we can detect gridlock if needed).
|
||||
|
||||
---
|
||||
|
||||
## 2. Core Data Structures (`card-game-app/engine.py`)
|
||||
|
||||
We will design a clean, object-oriented state engine decouple-able from `curses` to facilitate unit testing and the `--smoke` non-interactive test run.
|
||||
|
||||
### `Card`
|
||||
```python
|
||||
class Card:
|
||||
def __init__(self, rank: int, suit: str, face_up: bool = False):
|
||||
self.rank = rank # 1 (Ace) to 13 (King)
|
||||
self.suit = suit # 'S' (Spades), 'H' (Hearts), 'D' (Diamonds), 'C' (Clubs)
|
||||
self.face_up = face_up
|
||||
```
|
||||
|
||||
### `GameState`
|
||||
- **`tableau`**: `List[List[Card]]` - 10 columns.
|
||||
- **`stock`**: `List[Card]` - Decks/remaining cards.
|
||||
- **`completed_sequences`**: `int` - Count of removed sequences (0 to 8).
|
||||
- **`history`**: `List[Memento]` - For Undo functionality.
|
||||
- **`score`**: `int` - Starts at 500. Each move subtracts 1 point. Completing a sequence adds 100 points.
|
||||
|
||||
### Key Operations
|
||||
- `deal_initial()`: Shuffles and populates the tableau and stock.
|
||||
- `deal_from_stock()`: Deals 1 card to each column.
|
||||
- `can_move(from_col, card_idx, to_col)`: Validates if a move is legal.
|
||||
- `move_cards(from_col, card_idx, to_col)`: Executes the move, flips newly exposed bottom cards, and automatically extracts completed sequences.
|
||||
- `undo()`: Reverts the last state.
|
||||
- `check_win()`: Returns `True` if `completed_sequences == 8`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Terminal Rendering via Curses (`card-game-app/ui.py`)
|
||||
|
||||
Using the standard-library `curses` module, we will implement a full-screen, responsive interface.
|
||||
|
||||
### Layout Design
|
||||
```
|
||||
[SPIDER SOLITAIRE] Score: 495 Moves: 5 Suits: 1-Suit (S)
|
||||
==================================================================================
|
||||
Stock: [ [50] ] Completed: [K♠] [K♠] [ ] [ ] [ ] [ ] [ ] [ ]
|
||||
|
||||
Col 1 Col 2 Col 3 Col 4 Col 5 Col 6 Col 7 Col 8 Col 9 Col 10
|
||||
----- ----- ----- ----- ----- ----- ----- ----- ----- ------
|
||||
[ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ]
|
||||
[ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ]
|
||||
[ ] [ ] [ ] [ ] 10♠ [ ] [ ] [ ] [ ] [ ]
|
||||
J♠ 9♥ [ ] [ ] [ ] [ ] [ ] [ ] [ ]
|
||||
10♠ 8♦ K♣ [ ] [ ] [ ] [ ] [ ]
|
||||
7♦ Q♣ [ ] [ ] [ ] [ ]
|
||||
5♠ 2♦ [ ] [ ]
|
||||
A♦
|
||||
|
||||
==================================================================================
|
||||
Controls: [Arrow keys / Tab] Move cursor [Space/Enter] Select card/column
|
||||
[S] Deal Stock [U] Undo [R] New Game [Q] Quit
|
||||
```
|
||||
|
||||
### Visual Representation of Cards
|
||||
- Face-down card: `[░░░]` or blue block.
|
||||
- Face-up card: Rank + Suit symbol. Examples: `A♠`, `10♥`, `Q♦`, `K♣`.
|
||||
- Color schemes:
|
||||
- Spades/Clubs: White or default color.
|
||||
- Hearts/Diamonds: Red text (`curses.color_pair` with red foreground).
|
||||
- Selected card/sequence: Highlighted background (Reverse video or yellow background).
|
||||
|
||||
### Cursor & Selection Mechanics
|
||||
- **Grid-based selection / Keyboard cursor**:
|
||||
- The player moves a cursor (highlighted cell or arrow pointer) across columns.
|
||||
- Pressing `SPACE` or `ENTER` on a column selects the deepest movable sequence.
|
||||
- Moving the cursor to another column and pressing `SPACE`/`ENTER` attempts the move.
|
||||
- Alternative: Keyboard column shortcut keys (e.g., Press `1` through `0` to select source column, then press destination column). We will provide **both** cursor-based navigations and quick hotkeys for smooth UX.
|
||||
|
||||
---
|
||||
|
||||
## 4. Input Handling & Actions
|
||||
|
||||
| Input Key | Action |
|
||||
| --- | --- |
|
||||
| `LEFT` / `RIGHT` or `H` / `L` | Navigate left/right across columns |
|
||||
| `UP` / `DOWN` or `K` / `J` | Navigate up/down within a column to select the starting card of a sequence |
|
||||
| `SPACE` / `ENTER` | Select starting card of sequence / Drop sequence onto target column |
|
||||
| `S` | Deal a round from stock |
|
||||
| `U` | Undo last move |
|
||||
| `R` | Restart / New Game (prompts for difficulty: 1, 2, or 4 suits) |
|
||||
| `Q` / `ESC` | Exit game |
|
||||
|
||||
---
|
||||
|
||||
## 5. Non-Interactive Demo Verification (`--smoke`)
|
||||
|
||||
To satisfy the verification requirements without prompting for curses terminal initialization, `python3 main.py --smoke` will run a programmatic simulation of the solitaire game engine:
|
||||
1. Initialize a 1-suit Spider solitaire game.
|
||||
2. Verify the card count in columns (54) and stock (50).
|
||||
3. Find a legal move in the initial dealt state, execute it, and verify that columns and score updated.
|
||||
4. Deal from stock and verify stock size decreases by 10 and columns increase.
|
||||
5. Perform an undo and verify correctness.
|
||||
6. Print a JSON report of the execution status and exit with code `0`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Testing Strategy
|
||||
|
||||
### Unit Tests (`card-game-app/test_engine.py`)
|
||||
We will write lightweight and automated unit tests for:
|
||||
- Card model initialization and representation.
|
||||
- Complete deck shuffling and dealing proportions.
|
||||
- Move validation rules (successes and various invalid move rejections).
|
||||
- Automatic extraction and clearing of complete K-to-A sequences.
|
||||
- Stock deals and its pre-requisites (no empty columns).
|
||||
- Undo/redo correctness.
|
||||
|
||||
We can execute unit tests using standard library `unittest` or `pytest`:
|
||||
`python3 -m unittest card-game-app/test_engine.py`
|
||||
335
card-game-app/engine.py
Normal file
335
card-game-app/engine.py
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
import random
|
||||
import copy
|
||||
|
||||
# Ranks mapping for display
|
||||
RANK_NAMES = {
|
||||
1: 'A',
|
||||
2: '2',
|
||||
3: '3',
|
||||
4: '4',
|
||||
5: '5',
|
||||
6: '6',
|
||||
7: '7',
|
||||
8: '8',
|
||||
9: '9',
|
||||
10: '10',
|
||||
11: 'J',
|
||||
12: 'Q',
|
||||
13: 'K'
|
||||
}
|
||||
|
||||
SUIT_SYMBOLS = {
|
||||
'S': '♠', # Spades
|
||||
'H': '♥', # Hearts
|
||||
'D': '♦', # Diamonds
|
||||
'C': '♣' # Clubs
|
||||
}
|
||||
|
||||
class Card:
|
||||
def __init__(self, rank: int, suit: str, face_up: bool = False):
|
||||
self.rank = rank # 1 (Ace) to 13 (King)
|
||||
self.suit = suit # 'S', 'H', 'D', 'C'
|
||||
self.face_up = face_up
|
||||
|
||||
def __repr__(self):
|
||||
status = "up" if self.face_up else "down"
|
||||
return f"{RANK_NAMES[self.rank]}{SUIT_SYMBOLS[self.suit]} ({status})"
|
||||
|
||||
def display_str(self) -> str:
|
||||
if self.face_up:
|
||||
return f"{RANK_NAMES[self.rank]}{SUIT_SYMBOLS[self.suit]}"
|
||||
return "[░░░]"
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'rank': self.rank,
|
||||
'suit': self.suit,
|
||||
'face_up': self.face_up
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data):
|
||||
return cls(data['rank'], data['suit'], data['face_up'])
|
||||
|
||||
|
||||
class GameState:
|
||||
def __init__(self, difficulty: int = 1):
|
||||
"""
|
||||
difficulty: 1 (1-Suit: Spades), 2 (2-Suit: Spades, Hearts), 4 (4-Suit: Standard)
|
||||
"""
|
||||
if difficulty not in (1, 2, 4):
|
||||
difficulty = 1
|
||||
self.difficulty = difficulty
|
||||
self.tableau = [[] for _ in range(10)] # 10 columns
|
||||
self.stock = [] # stock pile
|
||||
self.completed_sequences = 0 # Count of completed K-A runs (0-8)
|
||||
self.completed_suits = [] # Track exact suits of completed runs
|
||||
self.score = 500 # Standard starting score
|
||||
self.moves_count = 0
|
||||
self.history = [] # Undo history
|
||||
|
||||
self.initialize_game()
|
||||
|
||||
def initialize_game(self):
|
||||
# Determine suits to use based on difficulty
|
||||
if self.difficulty == 1:
|
||||
suits = ['S'] * 8
|
||||
elif self.difficulty == 2:
|
||||
suits = ['S', 'H'] * 4
|
||||
else:
|
||||
suits = ['S', 'H', 'D', 'C'] * 2
|
||||
|
||||
# Create 104 cards (8 full 13-card runs)
|
||||
deck = []
|
||||
for suit in suits:
|
||||
for rank in range(1, 14):
|
||||
deck.append(Card(rank, suit, face_up=False))
|
||||
|
||||
# Shuffle deck
|
||||
random.shuffle(deck)
|
||||
|
||||
# Distribute cards to 10 columns
|
||||
# Columns 0-3: 6 cards each (5 face down, 1 face up)
|
||||
# Columns 4-9: 5 cards each (4 face down, 1 face up)
|
||||
self.tableau = [[] for _ in range(10)]
|
||||
for i in range(10):
|
||||
num_cards = 6 if i < 4 else 5
|
||||
for _ in range(num_cards):
|
||||
card = deck.pop()
|
||||
self.tableau[i].append(card)
|
||||
# Turn top card face up
|
||||
if self.tableau[i]:
|
||||
self.tableau[i][-1].face_up = True
|
||||
|
||||
# Remaining 50 cards go to stock
|
||||
self.stock = deck
|
||||
self.completed_sequences = 0
|
||||
self.completed_suits = []
|
||||
self.score = 500
|
||||
self.moves_count = 0
|
||||
self.history = []
|
||||
|
||||
def save_state_to_history(self):
|
||||
"""Save a deep-ish copy of state to allow undo"""
|
||||
state_copy = {
|
||||
'tableau': [[Card(c.rank, c.suit, c.face_up) for c in col] for col in self.tableau],
|
||||
'stock': [Card(c.rank, c.suit, c.face_up) for c in self.stock],
|
||||
'completed_sequences': self.completed_sequences,
|
||||
'completed_suits': list(self.completed_suits),
|
||||
'score': self.score,
|
||||
'moves_count': self.moves_count
|
||||
}
|
||||
self.history.append(state_copy)
|
||||
|
||||
def undo(self) -> bool:
|
||||
"""Revert to the last saved state"""
|
||||
if not self.history:
|
||||
return False
|
||||
prev_state = self.history.pop()
|
||||
self.tableau = prev_state['tableau']
|
||||
self.stock = prev_state['stock']
|
||||
self.completed_sequences = prev_state['completed_sequences']
|
||||
self.completed_suits = prev_state['completed_suits']
|
||||
self.score = prev_state['score']
|
||||
self.moves_count = prev_state['moves_count']
|
||||
return True
|
||||
|
||||
def can_deal_from_stock(self) -> bool:
|
||||
"""
|
||||
Stock deals 10 cards.
|
||||
Standard rules: stock cannot be dealt if any column is empty.
|
||||
Must also have at least 10 cards left in the stock.
|
||||
"""
|
||||
if len(self.stock) < 10:
|
||||
return False
|
||||
for col in self.tableau:
|
||||
if not col:
|
||||
return False
|
||||
return True
|
||||
|
||||
def deal_from_stock(self) -> bool:
|
||||
"""Deals 1 card to each of the 10 columns."""
|
||||
if not self.can_deal_from_stock():
|
||||
return False
|
||||
|
||||
self.save_state_to_history()
|
||||
|
||||
# Deal 10 cards
|
||||
for col_idx in range(10):
|
||||
card = self.stock.pop()
|
||||
card.face_up = True
|
||||
self.tableau[col_idx].append(card)
|
||||
|
||||
# After deal, check for any newly completed sequences in columns
|
||||
self.check_and_clear_all_completed_sequences()
|
||||
|
||||
self.score -= 1
|
||||
self.moves_count += 1
|
||||
return True
|
||||
|
||||
def get_movable_sequence_start_indices(self, col_idx: int) -> list:
|
||||
"""
|
||||
Returns a list of starting indices of all valid movable sequences in a column.
|
||||
A sequence is movable if:
|
||||
1. All cards in the sequence are face_up.
|
||||
2. The cards are in consecutive descending ranks (e.g. 7, 6, 5).
|
||||
3. All cards in the sequence have the SAME suit.
|
||||
"""
|
||||
col = self.tableau[col_idx]
|
||||
if not col:
|
||||
return []
|
||||
|
||||
movable_indices = []
|
||||
n = len(col)
|
||||
|
||||
# Check from the bottom-most card upwards
|
||||
for start_idx in range(n - 1, -1, -1):
|
||||
# If the starting card is not face-up, we cannot start a sequence here
|
||||
if not col[start_idx].face_up:
|
||||
break
|
||||
|
||||
# Verify sequence from start_idx to the end of the column
|
||||
is_valid = True
|
||||
current_suit = col[start_idx].suit
|
||||
for i in range(start_idx, n - 1):
|
||||
card1 = col[i]
|
||||
card2 = col[i+1]
|
||||
# Conditions: same suit, and rank of card2 is exactly card1 - 1
|
||||
if not card2.face_up or card2.suit != current_suit or card2.rank != card1.rank - 1:
|
||||
is_valid = False
|
||||
break
|
||||
|
||||
if is_valid:
|
||||
movable_indices.append(start_idx)
|
||||
else:
|
||||
# If a sequence from start_idx is not valid, any larger sequence containing it won't be valid either
|
||||
break
|
||||
|
||||
# Return indices sorted ascending (e.g., from top of sequence down to bottom)
|
||||
return sorted(movable_indices)
|
||||
|
||||
def can_move(self, from_col: int, start_idx: int, to_col: int) -> bool:
|
||||
"""
|
||||
Validates if moving the sequence starting at start_idx from from_col to to_col is legal.
|
||||
"""
|
||||
if from_col < 0 or from_col >= 10 or to_col < 0 or to_col >= 10:
|
||||
return False
|
||||
if from_col == to_col:
|
||||
return False
|
||||
|
||||
col_from = self.tableau[from_col]
|
||||
col_to = self.tableau[to_col]
|
||||
|
||||
# Valid range check
|
||||
if not col_from or start_idx < 0 or start_idx >= len(col_from):
|
||||
return False
|
||||
|
||||
# Is the sequence itself valid (descending, same suit, all face up)?
|
||||
valid_starts = self.get_movable_sequence_start_indices(from_col)
|
||||
if start_idx not in valid_starts:
|
||||
return False
|
||||
|
||||
# Can it be placed on target column?
|
||||
if not col_to:
|
||||
# Empty column can accept any valid sequence
|
||||
return True
|
||||
|
||||
# Target column is not empty; top card must be rank of moving_card + 1 (suit doesn't matter)
|
||||
target_card = col_to[-1]
|
||||
moving_card = col_from[start_idx]
|
||||
if target_card.rank == moving_card.rank + 1:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def move_cards(self, from_col: int, start_idx: int, to_col: int) -> bool:
|
||||
"""Executes a move from from_col to to_col, handling score, revealing, and completions."""
|
||||
if not self.can_move(from_col, start_idx, to_col):
|
||||
return False
|
||||
|
||||
self.save_state_to_history()
|
||||
|
||||
col_from = self.tableau[from_col]
|
||||
col_to = self.tableau[to_col]
|
||||
|
||||
# Extract sequence
|
||||
moving_cards = col_from[start_idx:]
|
||||
self.tableau[from_col] = col_from[:start_idx]
|
||||
|
||||
# Place on target
|
||||
col_to.extend(moving_cards)
|
||||
|
||||
# Flip the new bottom card of the source column if it's facedown
|
||||
if self.tableau[from_col] and not self.tableau[from_col][-1].face_up:
|
||||
self.tableau[from_col][-1].face_up = True
|
||||
|
||||
# Check for sequence completions across all columns
|
||||
self.check_and_clear_all_completed_sequences()
|
||||
|
||||
self.score -= 1
|
||||
self.moves_count += 1
|
||||
return True
|
||||
|
||||
def check_and_clear_all_completed_sequences(self):
|
||||
"""
|
||||
Scan all 10 columns. If the bottom 13 cards of a column form a complete
|
||||
descending same-suit sequence from King (13) down to Ace (1), remove them
|
||||
and increment completed count.
|
||||
Repeat until no more completed sequences are found.
|
||||
"""
|
||||
cleared_any = True
|
||||
while cleared_any:
|
||||
cleared_any = False
|
||||
for col_idx in range(10):
|
||||
col = self.tableau[col_idx]
|
||||
if len(col) < 13:
|
||||
continue
|
||||
|
||||
# Check bottom 13 cards
|
||||
candidate_cards = col[-13:]
|
||||
|
||||
# Check if all 13 cards are face_up, same suit, and descending from 13 to 1
|
||||
suit = candidate_cards[0].suit
|
||||
is_completed = True
|
||||
for i, card in enumerate(candidate_cards):
|
||||
expected_rank = 13 - i
|
||||
if not card.face_up or card.suit != suit or card.rank != expected_rank:
|
||||
is_completed = False
|
||||
break
|
||||
|
||||
if is_completed:
|
||||
# Remove the completed sequence
|
||||
self.tableau[col_idx] = col[:-13]
|
||||
self.completed_sequences += 1
|
||||
self.completed_suits.append(suit)
|
||||
self.score += 100
|
||||
|
||||
# Reveal the newly exposed bottom card of the column
|
||||
if self.tableau[col_idx] and not self.tableau[col_idx][-1].face_up:
|
||||
self.tableau[col_idx][-1].face_up = True
|
||||
|
||||
cleared_any = True
|
||||
break # Restart scan since tableau state has changed
|
||||
|
||||
def has_any_moves(self) -> bool:
|
||||
"""
|
||||
Detects if there is any valid move available on the board.
|
||||
Does not check stock deals (stock deal is always an option if stock not empty).
|
||||
"""
|
||||
# If stock is not empty, there is a potential action (even if we need to clear empty cols first)
|
||||
if len(self.stock) >= 10:
|
||||
return True
|
||||
|
||||
# Check all possible from/to column combinations
|
||||
for from_col in range(10):
|
||||
valid_starts = self.get_movable_sequence_start_indices(from_col)
|
||||
for start_idx in valid_starts:
|
||||
for to_col in range(10):
|
||||
if from_col == to_col:
|
||||
continue
|
||||
if self.can_move(from_col, start_idx, to_col):
|
||||
return True
|
||||
return False
|
||||
|
||||
def is_won(self) -> bool:
|
||||
return self.completed_sequences == 8
|
||||
432
card-game-app/main.py
Normal file
432
card-game-app/main.py
Normal file
|
|
@ -0,0 +1,432 @@
|
|||
import sys
|
||||
import os
|
||||
import json
|
||||
|
||||
# Add current directory to path to ensure relative imports work reliably
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from engine import GameState, Card, RANK_NAMES, SUIT_SYMBOLS
|
||||
|
||||
def run_smoke_test():
|
||||
print("Running non-interactive smoke test verification...")
|
||||
|
||||
# 1. Initialize a 1-suit Spider solitaire game
|
||||
state = GameState(difficulty=1)
|
||||
|
||||
# 2. Verify the card count in columns (54) and stock (50)
|
||||
total_tableau = sum(len(col) for col in state.tableau)
|
||||
print(f"Tableau card count: {total_tableau} (Expected: 54)")
|
||||
assert total_tableau == 54, f"Tableau card count must be 54, got {total_tableau}"
|
||||
|
||||
stock_count = len(state.stock)
|
||||
print(f"Stock card count: {stock_count} (Expected: 50)")
|
||||
assert stock_count == 50, f"Stock card count must be 50, got {stock_count}"
|
||||
|
||||
# 3. Force-create a valid move in the initial state to ensure 100% determinism
|
||||
# Set top of col 0 to Q of Spades (12) and top of col 1 to J of Spades (11)
|
||||
state.tableau[0][-1] = Card(12, 'S', face_up=True)
|
||||
state.tableau[1][-1] = Card(11, 'S', face_up=True)
|
||||
|
||||
idx_col1 = len(state.tableau[1]) - 1
|
||||
print("Checking move validation...")
|
||||
can_m = state.can_move(1, idx_col1, 0)
|
||||
print(f"Can move J onto Q? {can_m}")
|
||||
assert can_m, "Deterministic move should be valid"
|
||||
|
||||
print("Executing move...")
|
||||
success = state.move_cards(1, idx_col1, 0)
|
||||
print(f"Move success? {success}")
|
||||
assert success, "Move execution must succeed"
|
||||
|
||||
# Verify columns and score updated
|
||||
assert len(state.tableau[1]) == 5, f"Col 1 should have 5 cards, got {len(state.tableau[1])}"
|
||||
assert len(state.tableau[0]) == 7, f"Col 0 should have 7 cards, got {len(state.tableau[0])}"
|
||||
assert state.tableau[0][-1].rank == 11, "Col 0 top card should be J"
|
||||
assert state.tableau[0][-2].rank == 12, "Col 0 second top card should be Q"
|
||||
assert state.score == 499, f"Score should be 499, got {state.score}"
|
||||
assert state.moves_count == 1, f"Moves count should be 1, got {state.moves_count}"
|
||||
|
||||
# 4. Deal from stock and verify stock size decreases by 10 and columns increase
|
||||
print("Dealing from stock...")
|
||||
deal_ok = state.deal_from_stock()
|
||||
print(f"Deal success? {deal_ok}")
|
||||
assert deal_ok, "Deal from stock must succeed"
|
||||
assert len(state.stock) == 40, f"Stock size should be 40, got {len(state.stock)}"
|
||||
assert len(state.tableau[0]) == 8, f"Col 0 should now have 8 cards, got {len(state.tableau[0])}"
|
||||
assert state.score == 498, f"Score should be 498, got {state.score}"
|
||||
assert state.moves_count == 2, f"Moves count should be 2, got {state.moves_count}"
|
||||
|
||||
# 5. Perform an undo and verify correctness
|
||||
print("Undoing deal...")
|
||||
undo_ok = state.undo()
|
||||
print(f"Undo success? {undo_ok}")
|
||||
assert undo_ok, "Undo must succeed"
|
||||
assert len(state.stock) == 50, f"Stock size should return to 50, got {len(state.stock)}"
|
||||
assert len(state.tableau[0]) == 7, f"Col 0 should return to 7 cards, got {len(state.tableau[0])}"
|
||||
assert state.score == 499, f"Score should return to 499, got {state.score}"
|
||||
assert state.moves_count == 1, f"Moves count should return to 1, got {state.moves_count}"
|
||||
|
||||
# 6. Print JSON report of the execution status and exit with code 0
|
||||
report = {
|
||||
"outcome": "succeeded",
|
||||
"assertions_verified": True,
|
||||
"tableau_cards": total_tableau,
|
||||
"stock_cards": len(state.stock),
|
||||
"smoke_test_passed": True
|
||||
}
|
||||
print(json.dumps(report, indent=2))
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def get_card_color_pair(card, is_selected, is_cursor):
|
||||
is_red = card.suit in ('H', 'D')
|
||||
if is_cursor:
|
||||
return 4 if is_red else 3
|
||||
elif is_selected:
|
||||
return 6 if is_red else 5
|
||||
else:
|
||||
return 1 if is_red else 2
|
||||
|
||||
|
||||
def confirm_action(stdscr, prompt_text):
|
||||
height, width = stdscr.getmaxyx()
|
||||
stdscr.move(height - 3, 0)
|
||||
stdscr.clrtoeol()
|
||||
stdscr.addstr(height - 3, 2, f"{prompt_text} (y/n): ", curses.A_BOLD | curses.color_pair(8))
|
||||
stdscr.refresh()
|
||||
|
||||
while True:
|
||||
ch = stdscr.getch()
|
||||
if ch in (ord('y'), ord('Y')):
|
||||
return True
|
||||
elif ch in (ord('n'), ord('N'), 27):
|
||||
return False
|
||||
|
||||
|
||||
def choose_difficulty(stdscr):
|
||||
height, width = stdscr.getmaxyx()
|
||||
stdscr.move(height - 3, 0)
|
||||
stdscr.clrtoeol()
|
||||
stdscr.addstr(height - 3, 2, "Select Difficulty: [1] 1-Suit (Easy) [2] 2-Suit (Medium) [4] 4-Suit (Hard) (ESC to cancel): ", curses.A_BOLD | curses.color_pair(8))
|
||||
stdscr.refresh()
|
||||
|
||||
while True:
|
||||
ch = stdscr.getch()
|
||||
if ch == ord('1'):
|
||||
return 1
|
||||
elif ch == ord('2'):
|
||||
return 2
|
||||
elif ch == ord('4'):
|
||||
return 4
|
||||
elif ch == 27: # ESC
|
||||
return None
|
||||
|
||||
|
||||
def play_game(stdscr):
|
||||
# Hide standard cursor
|
||||
try:
|
||||
curses.curs_set(0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Init colors
|
||||
import curses
|
||||
try:
|
||||
curses.start_color()
|
||||
curses.use_default_colors()
|
||||
bg = -1
|
||||
except Exception:
|
||||
bg = curses.COLOR_BLACK
|
||||
|
||||
curses.init_pair(1, curses.COLOR_RED, bg) # Red card
|
||||
curses.init_pair(2, curses.COLOR_WHITE, bg) # Black card
|
||||
curses.init_pair(3, curses.COLOR_BLACK, curses.COLOR_CYAN) # Cursor (Black)
|
||||
curses.init_pair(4, curses.COLOR_RED, curses.COLOR_CYAN) # Cursor (Red)
|
||||
curses.init_pair(5, curses.COLOR_BLACK, curses.COLOR_YELLOW) # Selected (Black)
|
||||
curses.init_pair(6, curses.COLOR_RED, curses.COLOR_YELLOW) # Selected (Red)
|
||||
curses.init_pair(7, curses.COLOR_GREEN, bg) # Status success
|
||||
curses.init_pair(8, curses.COLOR_YELLOW, bg) # Status warn
|
||||
|
||||
# Start default game
|
||||
difficulty = 1
|
||||
state = GameState(difficulty)
|
||||
|
||||
cursor_col = 0
|
||||
cursor_row = len(state.tableau[cursor_col]) - 1
|
||||
|
||||
selected_col = None
|
||||
selected_card_idx = None
|
||||
|
||||
status_message = "🕷️ Welcome to Spider Solitaire! Use Arrow keys to move cursor, Enter/Space to select."
|
||||
status_is_error = False
|
||||
|
||||
while True:
|
||||
stdscr.erase()
|
||||
height, width = stdscr.getmaxyx()
|
||||
|
||||
# Guard for small screen sizes
|
||||
if width < 80 or height < 22:
|
||||
stdscr.addstr(0, 0, "Terminal screen is too small!")
|
||||
stdscr.addstr(1, 0, f"Current: {width}x{height} (Required: at least 80x22)")
|
||||
stdscr.addstr(3, 0, "Please enlarge your terminal or press 'q' to Quit.")
|
||||
stdscr.refresh()
|
||||
ch = stdscr.getch()
|
||||
if ch in (ord('q'), ord('Q'), 27):
|
||||
break
|
||||
continue
|
||||
|
||||
# 1. Header Row
|
||||
stdscr.addstr(0, 2, "🕷️ SPIDER SOLITAIRE", curses.A_BOLD | curses.color_pair(7))
|
||||
diff_str = {1: "1-Suit (Easy)", 2: "2-Suit (Medium)", 4: "4-Suit (Hard)"}[state.difficulty]
|
||||
summary_str = f"Score: {state.score:<4} Moves: {state.moves_count:<4} Difficulty: {diff_str}"
|
||||
stdscr.addstr(0, width - len(summary_str) - 2, summary_str, curses.A_BOLD)
|
||||
stdscr.addstr(1, 0, "=" * width)
|
||||
|
||||
# 2. Stock & Foundation Row
|
||||
num_deals = len(state.stock) // 10
|
||||
stock_desc = "Stock: "
|
||||
stdscr.addstr(3, 2, stock_desc)
|
||||
for d in range(5):
|
||||
if d < num_deals:
|
||||
stdscr.addstr(3, 2 + len(stock_desc) + d * 6, "[░░░]", curses.color_pair(2))
|
||||
else:
|
||||
stdscr.addstr(3, 2 + len(stock_desc) + d * 6, "[ ]", curses.A_DIM)
|
||||
stdscr.addstr(3, 2 + len(stock_desc) + 5 * 6, f" ({num_deals} deals left)", curses.A_DIM)
|
||||
|
||||
completed_desc = "Completed: "
|
||||
stdscr.addstr(3, width - 48, completed_desc)
|
||||
for i in range(8):
|
||||
comp_x = width - 48 + len(completed_desc) + i * 5
|
||||
if i < len(state.completed_suits):
|
||||
suit = state.completed_suits[i]
|
||||
suit_sym = SUIT_SYMBOLS[suit]
|
||||
color_p = 1 if suit in ('H', 'D') else 2
|
||||
stdscr.addstr(3, comp_x, f"[K{suit_sym}]", curses.color_pair(color_p) | curses.A_BOLD)
|
||||
else:
|
||||
stdscr.addstr(3, comp_x, "[ ]", curses.A_DIM)
|
||||
|
||||
stdscr.addstr(4, 0, "-" * width)
|
||||
|
||||
# 3. Tableau Rendering
|
||||
for col_idx in range(10):
|
||||
col_x = col_idx * 8 + 1
|
||||
col_cards = state.tableau[col_idx]
|
||||
|
||||
# Label
|
||||
col_lbl_style = curses.A_BOLD
|
||||
if cursor_col == col_idx and selected_col is None:
|
||||
col_lbl_style |= curses.A_UNDERLINE
|
||||
stdscr.addstr(5, col_x, f" Col {col_idx+1:<2}", col_lbl_style)
|
||||
stdscr.addstr(6, col_x, "------")
|
||||
|
||||
# Get counts and partition
|
||||
num_fd = sum(1 for c in col_cards if not c.face_up)
|
||||
|
||||
y = 7
|
||||
if num_fd > 0:
|
||||
stdscr.addstr(y, col_x, f"[░x{num_fd}]", curses.color_pair(2))
|
||||
y += 1
|
||||
|
||||
if not col_cards:
|
||||
# Column is empty
|
||||
if cursor_col == col_idx:
|
||||
stdscr.addstr(y, col_x, "[ - ]", curses.color_pair(3))
|
||||
else:
|
||||
stdscr.addstr(y, col_x, "[---]", curses.A_DIM)
|
||||
else:
|
||||
for card_idx, card in enumerate(col_cards):
|
||||
if not card.face_up:
|
||||
continue
|
||||
|
||||
is_cur = (col_idx == cursor_col and card_idx == cursor_row)
|
||||
is_sel = (selected_col == col_idx and card_idx >= selected_card_idx)
|
||||
|
||||
pair = get_card_color_pair(card, is_sel, is_cur)
|
||||
card_str = f"[{RANK_NAMES[card.rank]:>2}{SUIT_SYMBOLS[card.suit]}]"
|
||||
|
||||
draw_row = 7 + (1 if num_fd > 0 else 0) + (card_idx - num_fd)
|
||||
if draw_row < height - 5:
|
||||
stdscr.addstr(draw_row, col_x, card_str, curses.color_pair(pair))
|
||||
|
||||
# 4. Footer & Control Panel
|
||||
stdscr.addstr(height - 5, 0, "=" * width)
|
||||
|
||||
status_style = curses.color_pair(8) if status_is_error else curses.color_pair(7)
|
||||
stdscr.addstr(height - 4, 2, status_message[:width-4], status_style | curses.A_BOLD)
|
||||
|
||||
instr_str = "Arrows/WASD: Move Cursor | Enter/Space: Select/Move | S: Deal Stock | U: Undo | R: Restart | Q: Quit"
|
||||
stdscr.addstr(height - 2, 2, instr_str[:width-4], curses.A_DIM)
|
||||
|
||||
stdscr.refresh()
|
||||
|
||||
# User input
|
||||
ch = stdscr.getch()
|
||||
|
||||
# Navigation
|
||||
if ch in (curses.KEY_LEFT, ord('h'), ord('H'), ord('a'), ord('A')):
|
||||
cursor_col = (cursor_col - 1) % 10
|
||||
col_cards = state.tableau[cursor_col]
|
||||
cursor_row = max(0, len(col_cards) - 1)
|
||||
status_message = f"Column {cursor_col + 1} selected."
|
||||
status_is_error = False
|
||||
|
||||
elif ch in (curses.KEY_RIGHT, ord('l'), ord('L'), ord('d'), ord('D')):
|
||||
cursor_col = (cursor_col + 1) % 10
|
||||
col_cards = state.tableau[cursor_col]
|
||||
cursor_row = max(0, len(col_cards) - 1)
|
||||
status_message = f"Column {cursor_col + 1} selected."
|
||||
status_is_error = False
|
||||
|
||||
elif ch in (curses.KEY_UP, ord('k'), ord('K'), ord('w'), ord('W')):
|
||||
if selected_col is None:
|
||||
col_cards = state.tableau[cursor_col]
|
||||
num_fd = sum(1 for c in col_cards if not c.face_up)
|
||||
if len(col_cards) > 0:
|
||||
cursor_row = max(num_fd, cursor_row - 1)
|
||||
else:
|
||||
status_message = "Locked on selected sequence. Choose destination column and press Enter/Space."
|
||||
status_is_error = True
|
||||
|
||||
elif ch in (curses.KEY_DOWN, ord('j'), ord('J'), ord('s'), ord('S')) and ch not in (ord('s'), ord('S')):
|
||||
if selected_col is None:
|
||||
col_cards = state.tableau[cursor_col]
|
||||
if len(col_cards) > 0:
|
||||
cursor_row = min(len(col_cards) - 1, cursor_row + 1)
|
||||
else:
|
||||
status_message = "Locked on selected sequence. Choose destination column and press Enter/Space."
|
||||
status_is_error = True
|
||||
|
||||
elif ch in (ord(' '), 10, 13, curses.KEY_ENTER):
|
||||
if selected_col is None:
|
||||
# Select sequence
|
||||
col_cards = state.tableau[cursor_col]
|
||||
if not col_cards:
|
||||
status_message = "Cannot select from an empty column!"
|
||||
status_is_error = True
|
||||
else:
|
||||
valid_starts = state.get_movable_sequence_start_indices(cursor_col)
|
||||
if cursor_row in valid_starts:
|
||||
selected_col = cursor_col
|
||||
selected_card_idx = cursor_row
|
||||
status_message = f"Selected cards from Col {selected_col + 1}. Choose target column and press Enter."
|
||||
status_is_error = False
|
||||
else:
|
||||
status_message = "Invalid selection! Cards must be descending and of the same suit."
|
||||
status_is_error = True
|
||||
else:
|
||||
# Attempt move
|
||||
if cursor_col == selected_col:
|
||||
# Deselect
|
||||
selected_col = None
|
||||
selected_card_idx = None
|
||||
status_message = "Selection cleared."
|
||||
status_is_error = False
|
||||
else:
|
||||
success = state.move_cards(selected_col, selected_card_idx, cursor_col)
|
||||
if success:
|
||||
selected_col = None
|
||||
selected_card_idx = None
|
||||
status_message = "Moved successfully!"
|
||||
status_is_error = False
|
||||
|
||||
# Set cursor row to bottom of new column
|
||||
cursor_row = max(0, len(state.tableau[cursor_col]) - 1)
|
||||
|
||||
# Check Win
|
||||
if state.is_won():
|
||||
stdscr.erase()
|
||||
stdscr.addstr(height // 2 - 2, (width - 40) // 2, "🎉 CONGRATULATIONS! YOU WON! 🎉", curses.A_BOLD | curses.color_pair(7))
|
||||
stdscr.addstr(height // 2, (width - 30) // 2, f"Final Score: {state.score}", curses.A_BOLD)
|
||||
stdscr.addstr(height // 2 + 1, (width - 30) // 2, f"Total Moves: {state.moves_count}", curses.A_BOLD)
|
||||
stdscr.addstr(height // 2 + 3, (width - 40) // 2, "Press any key to exit...", curses.A_DIM)
|
||||
stdscr.refresh()
|
||||
stdscr.getch()
|
||||
break
|
||||
else:
|
||||
status_message = "Invalid move! Target card must be 1 rank higher than selected card."
|
||||
status_is_error = True
|
||||
|
||||
elif ch in (ord('c'), ord('C'), 27): # ESC or C clears selection
|
||||
if selected_col is not None:
|
||||
selected_col = None
|
||||
selected_card_idx = None
|
||||
status_message = "Selection cleared."
|
||||
status_is_error = False
|
||||
else:
|
||||
# Prompt Quit on Esc if nothing is selected
|
||||
if confirm_action(stdscr, "Are you sure you want to quit?"):
|
||||
break
|
||||
else:
|
||||
status_message = "Quit cancelled."
|
||||
status_is_error = False
|
||||
|
||||
elif ch in (ord('s'), ord('S')):
|
||||
if not state.can_deal_from_stock():
|
||||
if len(state.stock) < 10:
|
||||
status_message = "Stock is empty!"
|
||||
else:
|
||||
status_message = "Cannot deal: all empty columns must be filled first!"
|
||||
status_is_error = True
|
||||
else:
|
||||
success = state.deal_from_stock()
|
||||
if success:
|
||||
selected_col = None
|
||||
selected_card_idx = None
|
||||
cursor_row = max(0, len(state.tableau[cursor_col]) - 1)
|
||||
status_message = "Dealt 10 cards from the stock!"
|
||||
status_is_error = False
|
||||
|
||||
elif ch in (ord('u'), ord('U')):
|
||||
if state.undo():
|
||||
selected_col = None
|
||||
selected_card_idx = None
|
||||
cursor_row = max(0, len(state.tableau[cursor_col]) - 1)
|
||||
status_message = "Last move undone."
|
||||
status_is_error = False
|
||||
else:
|
||||
status_message = "Nothing to undo!"
|
||||
status_is_error = True
|
||||
|
||||
elif ch in (ord('r'), ord('R')):
|
||||
if confirm_action(stdscr, "Are you sure you want to restart?"):
|
||||
diff = choose_difficulty(stdscr)
|
||||
if diff is not None:
|
||||
difficulty = diff
|
||||
state = GameState(difficulty)
|
||||
cursor_col = 0
|
||||
cursor_row = len(state.tableau[cursor_col]) - 1
|
||||
selected_col = None
|
||||
selected_card_idx = None
|
||||
status_message = f"Started a new {difficulty}-Suit game!"
|
||||
status_is_error = False
|
||||
else:
|
||||
status_message = "Restart cancelled."
|
||||
status_is_error = False
|
||||
|
||||
elif ch in (ord('q'), ord('Q')):
|
||||
if confirm_action(stdscr, "Are you sure you want to quit?"):
|
||||
break
|
||||
else:
|
||||
status_message = "Quit cancelled."
|
||||
status_is_error = False
|
||||
|
||||
elif ch == curses.KEY_RESIZE:
|
||||
# Re-read terminal dimensions next loop
|
||||
pass
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) > 1 and sys.argv[1] == '--smoke':
|
||||
run_smoke_test()
|
||||
else:
|
||||
import curses
|
||||
from curses import wrapper
|
||||
try:
|
||||
wrapper(play_game)
|
||||
except KeyboardInterrupt:
|
||||
print("\nGame exited.")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
173
card-game-app/test_engine.py
Normal file
173
card-game-app/test_engine.py
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
import unittest
|
||||
from engine import GameState, Card, RANK_NAMES, SUIT_SYMBOLS
|
||||
|
||||
class TestSpiderSolitaireEngine(unittest.TestCase):
|
||||
def test_initialization_1_suit(self):
|
||||
state = GameState(difficulty=1)
|
||||
# Check initial totals
|
||||
self.assertEqual(len(state.stock), 50)
|
||||
total_tableau_cards = sum(len(col) for col in state.tableau)
|
||||
self.assertEqual(total_tableau_cards, 54)
|
||||
self.assertEqual(state.completed_sequences, 0)
|
||||
self.assertEqual(state.score, 500)
|
||||
self.assertEqual(state.moves_count, 0)
|
||||
|
||||
# Columns 1-4 (indices 0-3) should have 6 cards, last is face-up
|
||||
for i in range(4):
|
||||
self.assertEqual(len(state.tableau[i]), 6)
|
||||
self.assertTrue(state.tableau[i][-1].face_up)
|
||||
self.assertFalse(state.tableau[i][0].face_up)
|
||||
|
||||
# Columns 5-10 (indices 4-9) should have 5 cards, last is face-up
|
||||
for i in range(4, 10):
|
||||
self.assertEqual(len(state.tableau[i]), 5)
|
||||
self.assertTrue(state.tableau[i][-1].face_up)
|
||||
self.assertFalse(state.tableau[i][0].face_up)
|
||||
|
||||
# Verify all cards are Spades ('S')
|
||||
for col in state.tableau:
|
||||
for card in col:
|
||||
self.assertEqual(card.suit, 'S')
|
||||
for card in state.stock:
|
||||
self.assertEqual(card.suit, 'S')
|
||||
|
||||
def test_initialization_2_suit(self):
|
||||
state = GameState(difficulty=2)
|
||||
suits = set()
|
||||
for col in state.tableau:
|
||||
for card in col:
|
||||
suits.add(card.suit)
|
||||
for card in state.stock:
|
||||
suits.add(card.suit)
|
||||
self.assertEqual(suits, {'S', 'H'})
|
||||
|
||||
def test_initialization_4_suit(self):
|
||||
state = GameState(difficulty=4)
|
||||
suits = set()
|
||||
for col in state.tableau:
|
||||
for card in col:
|
||||
suits.add(card.suit)
|
||||
for card in state.stock:
|
||||
suits.add(card.suit)
|
||||
self.assertEqual(suits, {'S', 'H', 'D', 'C'})
|
||||
|
||||
def test_can_deal_from_stock_restrictions(self):
|
||||
state = GameState(difficulty=1)
|
||||
# Initially, all columns have cards, so deal should be allowed
|
||||
self.assertTrue(state.can_deal_from_stock())
|
||||
|
||||
# If we empty a column, deal is blocked
|
||||
state.tableau[0] = []
|
||||
self.assertFalse(state.can_deal_from_stock())
|
||||
|
||||
def test_deal_from_stock_execution(self):
|
||||
state = GameState(difficulty=1)
|
||||
initial_stock_len = len(state.stock)
|
||||
self.assertTrue(state.deal_from_stock())
|
||||
self.assertEqual(len(state.stock), initial_stock_len - 10)
|
||||
self.assertEqual(state.score, 499)
|
||||
self.assertEqual(state.moves_count, 1)
|
||||
for col in state.tableau:
|
||||
self.assertTrue(col[-1].face_up)
|
||||
|
||||
def test_movable_sequence_start_indices(self):
|
||||
state = GameState(difficulty=1)
|
||||
# Construct a known column state:
|
||||
# facedown, facedown, 8S (faceup), 7S (faceup), 6S (faceup)
|
||||
state.tableau[0] = [
|
||||
Card(10, 'S', face_up=False),
|
||||
Card(9, 'S', face_up=False),
|
||||
Card(8, 'S', face_up=True),
|
||||
Card(7, 'S', face_up=True),
|
||||
Card(6, 'S', face_up=True),
|
||||
]
|
||||
indices = state.get_movable_sequence_start_indices(0)
|
||||
# Expected movable starts are indices 2, 3, 4 (because [8,7,6], [7,6], [6] are all valid descending)
|
||||
self.assertEqual(indices, [2, 3, 4])
|
||||
|
||||
# If ranks don't match, sequence breaks
|
||||
state.tableau[0] = [
|
||||
Card(8, 'S', face_up=True),
|
||||
Card(6, 'S', face_up=True), # Break descending order
|
||||
Card(5, 'S', face_up=True),
|
||||
]
|
||||
indices = state.get_movable_sequence_start_indices(0)
|
||||
self.assertEqual(indices, [1, 2]) # 6, 5 is valid sequence, but 8 is broken
|
||||
|
||||
# If suits don't match, sequence breaks (even with descending ranks)
|
||||
state.tableau[0] = [
|
||||
Card(8, 'S', face_up=True),
|
||||
Card(7, 'H', face_up=True), # Suit break
|
||||
Card(6, 'H', face_up=True),
|
||||
]
|
||||
indices = state.get_movable_sequence_start_indices(0)
|
||||
self.assertEqual(indices, [1, 2]) # 7H, 6H is valid, but 8S is broken because of suit
|
||||
|
||||
def test_move_cards_validation_and_execution(self):
|
||||
state = GameState(difficulty=1)
|
||||
# Col 0: 6S (face_up)
|
||||
# Col 1: 7S (face_up)
|
||||
state.tableau[0] = [Card(10, 'S', False), Card(6, 'S', True)]
|
||||
state.tableau[1] = [Card(10, 'S', False), Card(7, 'S', True)]
|
||||
|
||||
# Move 6S on top of 7S
|
||||
self.assertTrue(state.can_move(0, 1, 1))
|
||||
self.assertTrue(state.move_cards(0, 1, 1))
|
||||
|
||||
# Check results
|
||||
self.assertEqual(len(state.tableau[0]), 1)
|
||||
# The facedown 10S in Col 0 should have been flipped faceup
|
||||
self.assertTrue(state.tableau[0][0].face_up)
|
||||
|
||||
# Col 1 should now have 7S, 6S
|
||||
self.assertEqual(len(state.tableau[1]), 3)
|
||||
self.assertEqual(state.tableau[1][-2].rank, 7)
|
||||
self.assertEqual(state.tableau[1][-1].rank, 6)
|
||||
|
||||
# Move details
|
||||
self.assertEqual(state.score, 499)
|
||||
self.assertEqual(state.moves_count, 1)
|
||||
|
||||
def test_undo_functionality(self):
|
||||
state = GameState(difficulty=1)
|
||||
state.tableau[0] = [Card(10, 'S', False), Card(6, 'S', True)]
|
||||
state.tableau[1] = [Card(10, 'S', False), Card(7, 'S', True)]
|
||||
|
||||
# Move
|
||||
state.move_cards(0, 1, 1)
|
||||
self.assertEqual(state.moves_count, 1)
|
||||
self.assertEqual(state.score, 499)
|
||||
|
||||
# Undo
|
||||
self.assertTrue(state.undo())
|
||||
self.assertEqual(state.moves_count, 0)
|
||||
self.assertEqual(state.score, 500)
|
||||
self.assertEqual(len(state.tableau[0]), 2)
|
||||
self.assertFalse(state.tableau[0][0].face_up)
|
||||
self.assertTrue(state.tableau[0][1].face_up)
|
||||
self.assertEqual(len(state.tableau[1]), 2)
|
||||
|
||||
def test_sequence_completion_and_clearing(self):
|
||||
state = GameState(difficulty=1)
|
||||
# Construct a complete run of King down to Ace
|
||||
run = [Card(rank, 'S', face_up=True) for rank in range(13, 0, -1)]
|
||||
# Put it in col 0 with 2 facedown cards underneath
|
||||
state.tableau[0] = [
|
||||
Card(2, 'S', face_up=False),
|
||||
Card(3, 'S', face_up=False),
|
||||
] + run
|
||||
|
||||
# Trigger completion check (ordinarily done inside moves/deals, but we call it directly here)
|
||||
state.check_and_clear_all_completed_sequences()
|
||||
|
||||
# Completed sequences should be 1
|
||||
self.assertEqual(state.completed_sequences, 1)
|
||||
# The run of 13 cards should be removed from col 0
|
||||
self.assertEqual(len(state.tableau[0]), 2)
|
||||
# The top card of col 0 should now be faceup
|
||||
self.assertTrue(state.tableau[0][-1].face_up)
|
||||
# Score increases by 100 for completed sequence
|
||||
self.assertEqual(state.score, 600)
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
3
status.json
Normal file
3
status.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"outcome": "succeeded"
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue