mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-07 03:00:29 +00:00
fabro(01KT9X0N42W6PRBVH2N5914EQJ): expand_spec (succeeded)
Fabro-Run: 01KT9X0N42W6PRBVH2N5914EQJ
Fabro-Completed: 2
Fabro-Checkpoint: 0061c285b6
⚒️ Generated with [Fabro](https://fabro.sh)
This commit is contained in:
parent
497aaba6f2
commit
df1e9e39f6
6 changed files with 1028 additions and 0 deletions
144
.ai/solitaire-spec.md
Normal file
144
.ai/solitaire-spec.md
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
# Terminal Klondike Solitaire Spec
|
||||
|
||||
This document details the specification and implementation plan for a terminal-based Klondike Solitaire game in Python 3.11+ using the standard `curses` library.
|
||||
|
||||
The application is structured to decouple core game rules and state from the presentation layer. This allows full testability of game mechanics without initializing a terminal.
|
||||
|
||||
---
|
||||
|
||||
## 1. Directory Structure
|
||||
|
||||
All files are located in `solitaire-app/`:
|
||||
|
||||
```
|
||||
solitaire-app/
|
||||
├── main.py # Entry point: handles CLI args (including --smoke) and starts TUI
|
||||
├── game.py # Core Klondike Solitaire game model and rule engine (pure Python)
|
||||
├── ui.py # curses-based terminal user interface and input handler
|
||||
└── test_game.py # Automated unit tests for game logic
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Core Game Engine (`game.py`)
|
||||
|
||||
The game rules are modeled strictly around standard **Draw-One Klondike Solitaire**.
|
||||
|
||||
### 2.1 Domain Entities
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
@dataclass
|
||||
class Card:
|
||||
suit: str # 'H' (Hearts), 'D' (Diamonds), 'C' (Clubs), 'S' (Spades)
|
||||
rank: int # 1 (Ace) to 13 (King)
|
||||
is_face_up: bool = False
|
||||
|
||||
@property
|
||||
def is_red(self) -> bool:
|
||||
return self.suit in ('H', 'D')
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
# e.g., "A", "2"..."10", "J", "Q", "K"
|
||||
ranks = {1: "A", 11: "J", 12: "Q", 13: "K"}
|
||||
return ranks.get(self.rank, str(self.rank))
|
||||
```
|
||||
|
||||
### 2.2 Game State Model
|
||||
|
||||
`GameState` encapsulates all piles and tracks move history for **unlimited Undo** functionality:
|
||||
|
||||
- **Stock (`List[Card]`)**: Face-down draw pile.
|
||||
- **Waste (`List[Card]`)**: Face-up drawn cards.
|
||||
- **Foundations (`List[List[Card]]`)**: 4 piles, initially empty.
|
||||
- **Tableau (`List[List[Card]]`)**: 7 piles. Tableau $i$ has $i$ cards, with top card face-up.
|
||||
- **History (`List[dict]`)**: Deep copies or delta representations of prior states to support Undo.
|
||||
|
||||
### 2.3 Core Mechanics & Rules
|
||||
|
||||
- **Deal**: Shuffle a 52-card deck. Deal cards to Tableau columns (Col 1 has 1, Col 2 has 2... Col 7 has 7). Reveal the top card of each. Remaining 24 cards go to the Stock.
|
||||
- **Draw**: Pop 1 card from Stock and append to Waste (face-up). If Stock is empty, recycle Waste by reversing it back to Stock (so that the original draw order is maintained).
|
||||
- **Tableau-to-Tableau Moves**:
|
||||
- A card or a valid face-up build (stack of decreasing/alternating color cards) can be moved from column $A$ to column $B$.
|
||||
- Target column top card must be opposite color and exactly 1 rank higher.
|
||||
- If the target column is empty, only a King (rank 13) can be placed there.
|
||||
- **Tableau-to-Foundation Moves**:
|
||||
- Move the top card of a Tableau column to a Foundation.
|
||||
- If the Foundation is empty, only an Ace (rank 1) of that suit is allowed.
|
||||
- If not empty, the card must be of the same suit and exactly 1 rank higher than the current top card of that Foundation.
|
||||
- **Waste-to-Tableau/Foundation**:
|
||||
- Similar rules applied to the top card of the Waste pile.
|
||||
- **Foundation-to-Tableau**:
|
||||
- Allows pulling a card back down from a Foundation pile to a Tableau column (following Tableau placement rules).
|
||||
- **Auto-Flip**:
|
||||
- If a move exposes a face-down card at the top of a Tableau column, it must be automatically flipped face-up.
|
||||
- **Win Condition**:
|
||||
- All 4 Foundation piles contain 13 cards (ending with Kings).
|
||||
|
||||
---
|
||||
|
||||
## 3. Terminal TUI Layer (`ui.py`)
|
||||
|
||||
The UI layer runs in `curses` using a grid-based navigation scheme.
|
||||
|
||||
### 3.1 Layout Design
|
||||
|
||||
The screen is divided into two main zones:
|
||||
|
||||
```
|
||||
[Stock] [Waste] [F1] [F2] [F3] [F4]
|
||||
[ ] [9♦] [A♥] [ ] [ ] [ ]
|
||||
|
||||
|
||||
[Col 1] [Col 2] [Col 3] [Col 4] [Col 5] [Col 6] [Col 7]
|
||||
[10♣] [ ] [ ] [ ] [ ] [ ] [ ]
|
||||
[J♥] [ ] [ ] [ ] [ ] [ ]
|
||||
[Q♠] [ ] [ ] [ ] [ ]
|
||||
```
|
||||
|
||||
### 3.2 Keyboard Navigation & Controls
|
||||
|
||||
We implement a keyboard-driven cursor navigation scheme:
|
||||
|
||||
1. **Cursor (Highlighted Element)**:
|
||||
- Use **Arrow keys** or Vim keys (`h`, `j`, `k`, `l`) to move the cursor.
|
||||
- Piles are arranged on a virtual grid:
|
||||
- Top row: Stock, Waste, Foundation 1, Foundation 2, Foundation 3, Foundation 4.
|
||||
- Bottom row: Tableau columns 1 to 7.
|
||||
- When cursor is on a Tableau column, pressing **Up** or **Down** moves the cursor within the face-up cards of that column. This allows selecting a specific card in a stack to move a partial build!
|
||||
|
||||
2. **Selecting and Moving**:
|
||||
- **Space** or **Enter**:
|
||||
- If the cursor is on the **Stock** pile: trigger a Draw operation.
|
||||
- If no card/pile is currently selected: select the current pile (and card index if in Tableau) as the **source**. The selection is visually highlighted.
|
||||
- If a **source** is already selected: move the selected card/stack from the source pile to the currently highlighted pile (the **target**). Then clear selection.
|
||||
- **Escape** or `c`: Cancel current selection.
|
||||
|
||||
3. **Global Shortcuts**:
|
||||
- `u` or `U`: Undo last move.
|
||||
- `r` or `R`: Restart game (re-shuffles and deals).
|
||||
- `q` or `Q`: Quit game.
|
||||
|
||||
### 3.3 Text & Color Configuration
|
||||
|
||||
- Red suits (Hearts/Diamonds) are rendered with red foreground text on appropriate backgrounds.
|
||||
- Black suits (Clubs/Spades) are rendered with black or standard terminal color text.
|
||||
- Face-down cards are rendered as `[ ]` or `[#]`.
|
||||
- Face-up cards are rendered as `[10♦]`, `[A♥]`, `[K♠]`, `[Q♣]`, etc., using Unicode suit symbols or ASCII fallbacks (`H`, `D`, `C`, `S`).
|
||||
|
||||
---
|
||||
|
||||
## 4. Verification and Testing
|
||||
|
||||
### 4.1 Unit Testing (`test_game.py`)
|
||||
- Standard Python unit tests using `unittest`.
|
||||
- Covers deck setup, drawing, valid/invalid moves for all directions, auto-flip, and victory validation.
|
||||
- Runs purely in CLI without loading `curses`.
|
||||
|
||||
### 4.2 Smoke Mode
|
||||
- Run `python3 main.py --smoke`.
|
||||
- The entry point parses `--smoke`, verifies all imports, initializes a mock terminal/session state, runs tests, and immediately exits with exit code `0`.
|
||||
- Ensures zero-dependency execution environment checks.
|
||||
273
solitaire-app/game.py
Normal file
273
solitaire-app/game.py
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
import random
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional, Tuple, Dict, Any
|
||||
import copy
|
||||
|
||||
@dataclass
|
||||
class Card:
|
||||
suit: str # 'H' (Hearts), 'D' (Diamonds), 'C' (Clubs), 'S' (Spades)
|
||||
rank: int # 1 (Ace) to 13 (King)
|
||||
is_face_up: bool = False
|
||||
|
||||
@property
|
||||
def is_red(self) -> bool:
|
||||
return self.suit in ('H', 'D')
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
ranks = {1: "A", 11: "J", 12: "Q", 13: "K"}
|
||||
return ranks.get(self.rank, str(self.rank))
|
||||
|
||||
def __repr__(self) -> str:
|
||||
suit_syms = {'H': '♥', 'D': '♦', 'C': '♣', 'S': '♠'}
|
||||
sym = suit_syms.get(self.suit, self.suit)
|
||||
face = self.label + sym if self.is_face_up else "##"
|
||||
return f"[{face}]"
|
||||
|
||||
|
||||
class GameState:
|
||||
def __init__(self, seed: Optional[int] = None) -> None:
|
||||
self.stock: List[Card] = []
|
||||
self.waste: List[Card] = []
|
||||
self.foundations: List[List[Card]] = [[] for _ in range(4)]
|
||||
self.tableau: List[List[Card]] = [[] for _ in range(7)]
|
||||
self.history: List[Dict[str, Any]] = []
|
||||
self.seed = seed
|
||||
self.deal()
|
||||
|
||||
def serialize_state(self) -> Dict[str, Any]:
|
||||
"""Returns a deep copy of the current state of all piles."""
|
||||
return {
|
||||
"stock": copy.deepcopy(self.stock),
|
||||
"waste": copy.deepcopy(self.waste),
|
||||
"foundations": copy.deepcopy(self.foundations),
|
||||
"tableau": copy.deepcopy(self.tableau),
|
||||
}
|
||||
|
||||
def restore_state(self, state: Dict[str, Any]) -> None:
|
||||
"""Restores the state from a serialized state dictionary."""
|
||||
self.stock = copy.deepcopy(state["stock"])
|
||||
self.waste = copy.deepcopy(state["waste"])
|
||||
self.foundations = copy.deepcopy(state["foundations"])
|
||||
self.tableau = copy.deepcopy(state["tableau"])
|
||||
|
||||
def record_history(self) -> None:
|
||||
"""Saves current state to history before a mutating operation."""
|
||||
self.history.append(self.serialize_state())
|
||||
|
||||
def undo(self) -> bool:
|
||||
"""Undoes the last recorded move. Returns True if successful."""
|
||||
if not self.history:
|
||||
return False
|
||||
previous_state = self.history.pop()
|
||||
self.restore_state(previous_state)
|
||||
return True
|
||||
|
||||
def deal(self) -> None:
|
||||
"""Creates a standard 52-card deck, shuffles, and deals a new game."""
|
||||
suits = ['H', 'D', 'C', 'S']
|
||||
deck = [Card(suit=s, rank=r, is_face_up=False) for s in suits for r in range(1, 14)]
|
||||
|
||||
if self.seed is not None:
|
||||
random.seed(self.seed)
|
||||
else:
|
||||
random.seed()
|
||||
random.shuffle(deck)
|
||||
|
||||
self.stock = []
|
||||
self.waste = []
|
||||
self.foundations = [[] for _ in range(4)]
|
||||
self.tableau = [[] for _ in range(7)]
|
||||
self.history = []
|
||||
|
||||
# Deal to Tableau
|
||||
# Col 0 gets 1 card, Col 1 gets 2 cards, ..., Col 6 gets 7 cards
|
||||
for i in range(7):
|
||||
for j in range(i + 1):
|
||||
card = deck.pop()
|
||||
if j == i:
|
||||
card.is_face_up = True
|
||||
self.tableau[i].append(card)
|
||||
|
||||
# Remaining cards go to Stock (face-down)
|
||||
self.stock = deck
|
||||
|
||||
def draw(self) -> bool:
|
||||
"""Draws one card from Stock to Waste. Recycles Waste to Stock if Stock is empty."""
|
||||
self.record_history()
|
||||
|
||||
if not self.stock:
|
||||
if not self.waste:
|
||||
# Both empty, nothing to do
|
||||
self.history.pop() # don't save useless history
|
||||
return False
|
||||
# Recycle Waste back to Stock
|
||||
# When we flip waste back to stock, we preserve order by reversing
|
||||
self.stock = list(reversed(self.waste))
|
||||
for card in self.stock:
|
||||
card.is_face_up = False
|
||||
self.waste = []
|
||||
return True
|
||||
|
||||
card = self.stock.pop()
|
||||
card.is_face_up = True
|
||||
self.waste.append(card)
|
||||
return True
|
||||
|
||||
def can_move_tableau_to_tableau(self, src_col: int, dest_col: int, card_idx: int) -> bool:
|
||||
if not (0 <= src_col < 7) or not (0 <= dest_col < 7):
|
||||
return False
|
||||
if src_col == dest_col:
|
||||
return False
|
||||
|
||||
src_pile = self.tableau[src_col]
|
||||
if not src_pile or card_idx < 0 or card_idx >= len(src_pile):
|
||||
return False
|
||||
|
||||
moving_card = src_pile[card_idx]
|
||||
if not moving_card.is_face_up:
|
||||
return False
|
||||
|
||||
# If dest is empty, moving card must be a King (rank 13)
|
||||
dest_pile = self.tableau[dest_col]
|
||||
if not dest_pile:
|
||||
return moving_card.rank == 13
|
||||
|
||||
dest_card = dest_pile[-1]
|
||||
if not dest_card.is_face_up:
|
||||
return False
|
||||
|
||||
# Opposite color and rank exactly one less
|
||||
return (moving_card.is_red != dest_card.is_red) and (moving_card.rank == dest_card.rank - 1)
|
||||
|
||||
def can_move_waste_to_tableau(self, dest_col: int) -> bool:
|
||||
if not self.waste:
|
||||
return False
|
||||
if not (0 <= dest_col < 7):
|
||||
return False
|
||||
|
||||
moving_card = self.waste[-1]
|
||||
dest_pile = self.tableau[dest_col]
|
||||
|
||||
if not dest_pile:
|
||||
return moving_card.rank == 13
|
||||
|
||||
dest_card = dest_pile[-1]
|
||||
return (moving_card.is_red != dest_card.is_red) and (moving_card.rank == dest_card.rank - 1)
|
||||
|
||||
def can_move_waste_to_foundation(self, dest_found: int) -> bool:
|
||||
if not self.waste:
|
||||
return False
|
||||
if not (0 <= dest_found < 4):
|
||||
return False
|
||||
|
||||
moving_card = self.waste[-1]
|
||||
found_pile = self.foundations[dest_found]
|
||||
|
||||
if not found_pile:
|
||||
return moving_card.rank == 1 # Ace
|
||||
|
||||
top_found_card = found_pile[-1]
|
||||
return (moving_card.suit == top_found_card.suit) and (moving_card.rank == top_found_card.rank + 1)
|
||||
|
||||
def can_move_tableau_to_foundation(self, src_col: int, dest_found: int) -> bool:
|
||||
if not (0 <= src_col < 7) or not (0 <= dest_found < 4):
|
||||
return False
|
||||
|
||||
src_pile = self.tableau[src_col]
|
||||
if not src_pile:
|
||||
return False
|
||||
|
||||
moving_card = src_pile[-1]
|
||||
found_pile = self.foundations[dest_found]
|
||||
|
||||
if not found_pile:
|
||||
return moving_card.rank == 1 # Ace
|
||||
|
||||
top_found_card = found_pile[-1]
|
||||
return (moving_card.suit == top_found_card.suit) and (moving_card.rank == top_found_card.rank + 1)
|
||||
|
||||
def can_move_foundation_to_tableau(self, src_found: int, dest_col: int) -> bool:
|
||||
if not (0 <= src_found < 4) or not (0 <= dest_col < 7):
|
||||
return False
|
||||
|
||||
found_pile = self.foundations[src_found]
|
||||
if not found_pile:
|
||||
return False
|
||||
|
||||
moving_card = found_pile[-1]
|
||||
dest_pile = self.tableau[dest_col]
|
||||
|
||||
if not dest_pile:
|
||||
return moving_card.rank == 13
|
||||
|
||||
dest_card = dest_pile[-1]
|
||||
return (moving_card.is_red != dest_card.is_red) and (moving_card.rank == dest_card.rank - 1)
|
||||
|
||||
def auto_reveal(self, col_idx: int) -> None:
|
||||
"""Reveals the top card of a Tableau column if it is face-down."""
|
||||
pile = self.tableau[col_idx]
|
||||
if pile and not pile[-1].is_face_up:
|
||||
pile[-1].is_face_up = True
|
||||
|
||||
def move_tableau_to_tableau(self, src_col: int, dest_col: int, card_idx: int) -> bool:
|
||||
if not self.can_move_tableau_to_tableau(src_col, dest_col, card_idx):
|
||||
return False
|
||||
|
||||
self.record_history()
|
||||
src_pile = self.tableau[src_col]
|
||||
dest_pile = self.tableau[dest_col]
|
||||
|
||||
moving_stack = src_pile[card_idx:]
|
||||
self.tableau[src_col] = src_pile[:card_idx]
|
||||
dest_pile.extend(moving_stack)
|
||||
|
||||
self.auto_reveal(src_col)
|
||||
return True
|
||||
|
||||
def move_waste_to_tableau(self, dest_col: int) -> bool:
|
||||
if not self.can_move_waste_to_tableau(dest_col):
|
||||
return False
|
||||
|
||||
self.record_history()
|
||||
card = self.waste.pop()
|
||||
self.tableau[dest_col].append(card)
|
||||
return True
|
||||
|
||||
def move_waste_to_foundation(self, dest_found: int) -> bool:
|
||||
if not self.can_move_waste_to_foundation(dest_found):
|
||||
return False
|
||||
|
||||
self.record_history()
|
||||
card = self.waste.pop()
|
||||
self.foundations[dest_found].append(card)
|
||||
return True
|
||||
|
||||
def move_tableau_to_foundation(self, src_col: int, dest_found: int) -> bool:
|
||||
if not self.can_move_tableau_to_foundation(src_col, dest_found):
|
||||
return False
|
||||
|
||||
self.record_history()
|
||||
card = self.tableau[src_col].pop()
|
||||
self.foundations[dest_found].append(card)
|
||||
|
||||
self.auto_reveal(src_col)
|
||||
return True
|
||||
|
||||
def move_foundation_to_tableau(self, src_found: int, dest_col: int) -> bool:
|
||||
if not self.can_move_foundation_to_tableau(src_found, dest_col):
|
||||
return False
|
||||
|
||||
self.record_history()
|
||||
card = self.foundations[src_found].pop()
|
||||
self.tableau[dest_col].append(card)
|
||||
return True
|
||||
|
||||
def check_win(self) -> bool:
|
||||
"""Check if all four foundation piles are fully built up to Kings."""
|
||||
for found_pile in self.foundations:
|
||||
if len(found_pile) != 13:
|
||||
return False
|
||||
if found_pile[-1].rank != 13:
|
||||
return False
|
||||
return True
|
||||
74
solitaire-app/main.py
Normal file
74
solitaire-app/main.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import sys
|
||||
import argparse
|
||||
import unittest
|
||||
|
||||
def run_smoke_test() -> None:
|
||||
"""Proves imports work, instantiates core classes, and runs self-tests."""
|
||||
print("Running Solitaire smoke tests...")
|
||||
|
||||
# 1. Test Imports
|
||||
try:
|
||||
from game import Card, GameState
|
||||
from ui import SolitaireTUI
|
||||
print("✓ Successfully imported core modules (game, ui).")
|
||||
except Exception as e:
|
||||
print(f"✗ Failed to import core modules: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# 2. Test instantiation of core logic
|
||||
try:
|
||||
game = GameState(seed=123)
|
||||
print(f"✓ GameState instantiated. Stock size: {len(game.stock)} cards.")
|
||||
|
||||
tui = SolitaireTUI(game)
|
||||
print("✓ SolitaireTUI instantiated.")
|
||||
except Exception as e:
|
||||
print(f"✗ Failed to instantiate game components: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# 3. Run full unit tests to confirm rule-engine validity
|
||||
print("Running automated unit tests...")
|
||||
loader = unittest.TestLoader()
|
||||
# Discover and run tests in the solitaire-app folder
|
||||
from test_game import TestSolitaireGame
|
||||
suite = loader.loadTestsFromTestCase(TestSolitaireGame)
|
||||
runner = unittest.TextTestRunner(verbosity=1)
|
||||
result = runner.run(suite)
|
||||
|
||||
if result.wasSuccessful():
|
||||
print("✓ All automated rules unit tests passed successfully!")
|
||||
else:
|
||||
print("✗ Automated unit tests failed!")
|
||||
sys.exit(1)
|
||||
|
||||
print("Smoke mode passed successfully.")
|
||||
sys.exit(0)
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Terminal Klondike Solitaire")
|
||||
parser.add_argument(
|
||||
"--smoke",
|
||||
action="store_true",
|
||||
help="Run non-interactive smoke tests to verify imports, setup, and game rules."
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.smoke:
|
||||
run_smoke_test()
|
||||
|
||||
# Normal execution starts curses-based TUI
|
||||
import curses
|
||||
from game import GameState
|
||||
from ui import SolitaireTUI
|
||||
|
||||
game = GameState()
|
||||
tui = SolitaireTUI(game)
|
||||
|
||||
try:
|
||||
curses.wrapper(tui.run)
|
||||
except Exception as e:
|
||||
print(f"Error running solitaire TUI: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
130
solitaire-app/test_game.py
Normal file
130
solitaire-app/test_game.py
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import unittest
|
||||
from game import GameState, Card
|
||||
|
||||
class TestSolitaireGame(unittest.TestCase):
|
||||
def setUp(self):
|
||||
# Use a fixed seed for reproducible tests
|
||||
self.game = GameState(seed=42)
|
||||
|
||||
def test_initial_deal(self):
|
||||
# Verify 7 tableau columns have correct card counts (1 to 7)
|
||||
for i in range(7):
|
||||
self.assertEqual(len(self.game.tableau[i]), i + 1)
|
||||
# Top card of each column must be face-up
|
||||
self.assertTrue(self.game.tableau[i][-1].is_face_up)
|
||||
# Other cards in column must be face-down
|
||||
for j in range(i):
|
||||
self.assertFalse(self.game.tableau[i][j].is_face_up)
|
||||
|
||||
# Foundations must be empty initially
|
||||
for f in self.game.foundations:
|
||||
self.assertEqual(len(f), 0)
|
||||
|
||||
# Stock must contain the remaining cards (52 - 28 = 24)
|
||||
self.assertEqual(len(self.game.stock), 24)
|
||||
# Waste should be empty initially
|
||||
self.assertEqual(len(self.game.waste), 0)
|
||||
|
||||
def test_draw_and_recycle(self):
|
||||
initial_stock_len = len(self.game.stock)
|
||||
|
||||
# Draw all cards
|
||||
for _ in range(initial_stock_len):
|
||||
success = self.game.draw()
|
||||
self.assertTrue(success)
|
||||
|
||||
self.assertEqual(len(self.game.stock), 0)
|
||||
self.assertEqual(len(self.game.waste), initial_stock_len)
|
||||
|
||||
# Draw again to recycle
|
||||
success = self.game.draw()
|
||||
self.assertTrue(success)
|
||||
self.assertEqual(len(self.game.stock), initial_stock_len)
|
||||
self.assertEqual(len(self.game.waste), 0)
|
||||
|
||||
def test_cannot_move_invalid_tableau_to_tableau(self):
|
||||
# Try to move a card to an empty slot or invalid card
|
||||
# Setup specific tableau configuration manually to test rules
|
||||
self.game.tableau[0] = [Card(suit='H', rank=5, is_face_up=True)]
|
||||
self.game.tableau[1] = [Card(suit='S', rank=7, is_face_up=True)]
|
||||
|
||||
# Moving 5 of Hearts onto 7 of Spades is invalid (rank diff != 1)
|
||||
self.assertFalse(self.game.can_move_tableau_to_tableau(src_col=0, dest_col=1, card_idx=0))
|
||||
|
||||
# Moving 5 of Hearts onto an empty column is invalid (must be King)
|
||||
self.game.tableau[2] = []
|
||||
self.assertFalse(self.game.can_move_tableau_to_tableau(src_col=0, dest_col=2, card_idx=0))
|
||||
|
||||
def test_valid_tableau_to_tableau_move(self):
|
||||
self.game.tableau[0] = [Card(suit='H', rank=6, is_face_up=True)]
|
||||
self.game.tableau[1] = [
|
||||
Card(suit='C', rank=8, is_face_up=False),
|
||||
Card(suit='S', rank=7, is_face_up=True)
|
||||
]
|
||||
|
||||
# 6 of Hearts onto 7 of Spades: valid! (opposite color, rank = 7 - 1)
|
||||
self.assertTrue(self.game.can_move_tableau_to_tableau(src_col=0, dest_col=1, card_idx=0))
|
||||
|
||||
# Perform the move
|
||||
success = self.game.move_tableau_to_tableau(src_col=0, dest_col=1, card_idx=0)
|
||||
self.assertTrue(success)
|
||||
self.assertEqual(len(self.game.tableau[0]), 0)
|
||||
self.assertEqual(len(self.game.tableau[1]), 3)
|
||||
self.assertEqual(self.game.tableau[1][-1].rank, 6)
|
||||
|
||||
def test_auto_reveal(self):
|
||||
self.game.tableau[0] = [Card(suit='H', rank=6, is_face_up=True)]
|
||||
self.game.tableau[1] = [
|
||||
Card(suit='C', rank=8, is_face_up=False),
|
||||
Card(suit='S', rank=7, is_face_up=True)
|
||||
]
|
||||
|
||||
# Move 7 of Spades from tableau[1] to tableau[0] is invalid due to colors/ranks,
|
||||
# let's set up a valid case where a face-down card gets exposed and revealed.
|
||||
self.game.tableau[0] = [Card(suit='D', rank=8, is_face_up=True)]
|
||||
self.game.tableau[1] = [
|
||||
Card(suit='C', rank=10, is_face_up=False),
|
||||
Card(suit='S', rank=7, is_face_up=True)
|
||||
]
|
||||
|
||||
# Let's change tableau[0] top card to 8 of Diamonds (Red) and tableau[1] to 7 of Spades (Black)
|
||||
success = self.game.move_tableau_to_tableau(src_col=1, dest_col=0, card_idx=1)
|
||||
self.assertTrue(success)
|
||||
# Check that the face-down card left in tableau[1] (10 of Clubs) is now face-up!
|
||||
self.assertTrue(self.game.tableau[1][0].is_face_up)
|
||||
|
||||
def test_undo_functionality(self):
|
||||
self.game.tableau[0] = [Card(suit='D', rank=8, is_face_up=True)]
|
||||
self.game.tableau[1] = [
|
||||
Card(suit='C', rank=10, is_face_up=False),
|
||||
Card(suit='S', rank=7, is_face_up=True)
|
||||
]
|
||||
|
||||
initial_state = self.game.serialize_state()
|
||||
|
||||
success = self.game.move_tableau_to_tableau(src_col=1, dest_col=0, card_idx=1)
|
||||
self.assertTrue(success)
|
||||
|
||||
# Undo the move
|
||||
undo_success = self.game.undo()
|
||||
self.assertTrue(undo_success)
|
||||
|
||||
# Verify state is restored
|
||||
self.assertEqual(len(self.game.tableau[1]), 2)
|
||||
self.assertFalse(self.game.tableau[1][0].is_face_up)
|
||||
self.assertTrue(self.game.tableau[1][1].is_face_up)
|
||||
self.assertEqual(len(self.game.tableau[0]), 1)
|
||||
|
||||
def test_win_condition(self):
|
||||
# Empty game state check_win should be False
|
||||
self.assertFalse(self.game.check_win())
|
||||
|
||||
# Set up a winning board state
|
||||
suits = ['H', 'D', 'C', 'S']
|
||||
for i, suit in enumerate(suits):
|
||||
self.game.foundations[i] = [Card(suit=suit, rank=r, is_face_up=True) for r in range(1, 14)]
|
||||
|
||||
self.assertTrue(self.game.check_win())
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
404
solitaire-app/ui.py
Normal file
404
solitaire-app/ui.py
Normal file
|
|
@ -0,0 +1,404 @@
|
|||
import curses
|
||||
from game import GameState, Card
|
||||
from typing import Optional, Tuple
|
||||
|
||||
class SolitaireTUI:
|
||||
def __init__(self, game: GameState) -> None:
|
||||
self.game = game
|
||||
|
||||
# Cursor positioning
|
||||
# zone: 'top' or 'bottom'
|
||||
self.cursor_zone = 'top'
|
||||
# col: 0 to 6 (for 'top' or 'bottom')
|
||||
self.cursor_col = 0
|
||||
# card_idx: index within the Tableau column (only used when zone == 'bottom')
|
||||
self.cursor_card_idx = 0
|
||||
|
||||
# Selection state
|
||||
# (zone, col, card_idx) or None
|
||||
self.selected_pos: Optional[Tuple[str, int, int]] = None
|
||||
self.status_msg = "Welcome to Klondike Solitaire! Arrow/Vim keys to move, Space/Enter to select/draw."
|
||||
|
||||
def get_first_face_up_idx(self, col: int) -> int:
|
||||
"""Returns the index of the first face-up card in Tableau column col."""
|
||||
pile = self.game.tableau[col]
|
||||
for idx, card in enumerate(pile):
|
||||
if card.is_face_up:
|
||||
return idx
|
||||
return 0
|
||||
|
||||
def move_cursor(self, direction: str) -> None:
|
||||
if self.cursor_zone == 'top':
|
||||
if direction == 'left':
|
||||
if self.cursor_col > 0:
|
||||
self.cursor_col -= 1
|
||||
if self.cursor_col == 2: # Skip the gap column
|
||||
self.cursor_col = 1
|
||||
elif direction == 'right':
|
||||
if self.cursor_col < 6:
|
||||
self.cursor_col += 1
|
||||
if self.cursor_col == 2: # Skip the gap column
|
||||
self.cursor_col = 3
|
||||
elif direction == 'down':
|
||||
self.cursor_zone = 'bottom'
|
||||
# Transition to tableau column of same index
|
||||
pile_len = len(self.game.tableau[self.cursor_col])
|
||||
self.cursor_card_idx = max(0, pile_len - 1)
|
||||
elif direction == 'up':
|
||||
pass # Already at top row
|
||||
|
||||
elif self.cursor_zone == 'bottom':
|
||||
col = self.cursor_col
|
||||
pile = self.game.tableau[col]
|
||||
|
||||
if direction == 'left':
|
||||
if self.cursor_col > 0:
|
||||
self.cursor_col -= 1
|
||||
# Update card index to the top card of the new column
|
||||
new_pile_len = len(self.game.tableau[self.cursor_col])
|
||||
self.cursor_card_idx = max(0, new_pile_len - 1)
|
||||
elif direction == 'right':
|
||||
if self.cursor_col < 6:
|
||||
self.cursor_col += 1
|
||||
new_pile_len = len(self.game.tableau[self.cursor_col])
|
||||
self.cursor_card_idx = max(0, new_pile_len - 1)
|
||||
elif direction == 'up':
|
||||
first_face_up = self.get_first_face_up_idx(col)
|
||||
if pile and self.cursor_card_idx > first_face_up:
|
||||
self.cursor_card_idx -= 1
|
||||
else:
|
||||
# Transition to top row
|
||||
self.cursor_zone = 'top'
|
||||
# Map to closest top-row element (skip gap)
|
||||
if self.cursor_col == 2:
|
||||
self.cursor_col = 1
|
||||
elif direction == 'down':
|
||||
if pile and self.cursor_card_idx < len(pile) - 1:
|
||||
self.cursor_card_idx += 1
|
||||
|
||||
def handle_action(self) -> None:
|
||||
"""Handles Space/Enter press based on current cursor position."""
|
||||
if self.cursor_zone == 'top':
|
||||
if self.cursor_col == 0:
|
||||
# Draw from Stock to Waste
|
||||
success = self.game.draw()
|
||||
if success:
|
||||
self.status_msg = "Drawn card."
|
||||
else:
|
||||
self.status_msg = "Stock and Waste are empty."
|
||||
self.selected_pos = None # Clear any active selection
|
||||
elif self.cursor_col == 1:
|
||||
# Waste pile selected or moved to
|
||||
if not self.game.waste:
|
||||
self.status_msg = "Waste is empty."
|
||||
return
|
||||
|
||||
if self.selected_pos is None:
|
||||
self.selected_pos = ('top', 1, len(self.game.waste) - 1)
|
||||
self.status_msg = "Selected card from Waste. Choose target."
|
||||
else:
|
||||
# You can't move anything onto the waste pile
|
||||
self.status_msg = "Cannot move cards onto the Waste pile."
|
||||
self.selected_pos = None
|
||||
elif self.cursor_col >= 3:
|
||||
found_idx = self.cursor_col - 3
|
||||
if self.selected_pos is None:
|
||||
# Select from Foundation
|
||||
if not self.game.foundations[found_idx]:
|
||||
self.status_msg = "Foundation is empty."
|
||||
return
|
||||
self.selected_pos = ('top', self.cursor_col, len(self.game.foundations[found_idx]) - 1)
|
||||
self.status_msg = f"Selected from Foundation {found_idx + 1}."
|
||||
else:
|
||||
# Move to Foundation
|
||||
src_zone, src_col, src_card_idx = self.selected_pos
|
||||
success = False
|
||||
if src_zone == 'top' and src_col == 1:
|
||||
success = self.game.move_waste_to_foundation(found_idx)
|
||||
elif src_zone == 'bottom':
|
||||
success = self.game.move_tableau_to_foundation(src_col, found_idx)
|
||||
|
||||
if success:
|
||||
self.status_msg = f"Moved to Foundation {found_idx + 1}."
|
||||
if self.game.check_win():
|
||||
self.status_msg = "CONGRATULATIONS! YOU WON THE GAME!"
|
||||
else:
|
||||
self.status_msg = "Invalid move to Foundation."
|
||||
self.selected_pos = None
|
||||
|
||||
elif self.cursor_zone == 'bottom':
|
||||
dest_col = self.cursor_col
|
||||
if self.selected_pos is None:
|
||||
# Select Tableau pile/card
|
||||
pile = self.game.tableau[dest_col]
|
||||
if not pile:
|
||||
self.status_msg = "Tableau column is empty."
|
||||
return
|
||||
# Verify chosen card is face up
|
||||
if not pile[self.cursor_card_idx].is_face_up:
|
||||
self.status_msg = "Cannot select face-down card."
|
||||
return
|
||||
self.selected_pos = ('bottom', dest_col, self.cursor_card_idx)
|
||||
self.status_msg = f"Selected cards from Column {dest_col + 1}."
|
||||
else:
|
||||
# Execute move to Tableau column
|
||||
src_zone, src_col, src_card_idx = self.selected_pos
|
||||
success = False
|
||||
if src_zone == 'top' and src_col == 1:
|
||||
success = self.game.move_waste_to_tableau(dest_col)
|
||||
elif src_zone == 'top' and src_col >= 3:
|
||||
success = self.game.move_foundation_to_tableau(src_col - 3, dest_col)
|
||||
elif src_zone == 'bottom':
|
||||
success = self.game.move_tableau_to_tableau(src_col, dest_col, src_card_idx)
|
||||
|
||||
if success:
|
||||
self.status_msg = "Move successful."
|
||||
else:
|
||||
self.status_msg = "Invalid move."
|
||||
|
||||
# Update cursor_card_idx to the new top card of destination
|
||||
new_len = len(self.game.tableau[dest_col])
|
||||
self.cursor_card_idx = max(0, new_len - 1)
|
||||
self.selected_pos = None
|
||||
|
||||
def handle_auto_move(self) -> None:
|
||||
"""Tries to automatically move the current highlighted card to any valid foundation."""
|
||||
success = False
|
||||
target_found = -1
|
||||
|
||||
if self.cursor_zone == 'top' and self.cursor_col == 1:
|
||||
# Try to move from waste to any foundation
|
||||
for f_idx in range(4):
|
||||
if self.game.can_move_waste_to_foundation(f_idx):
|
||||
success = self.game.move_waste_to_foundation(f_idx)
|
||||
target_found = f_idx
|
||||
break
|
||||
elif self.cursor_zone == 'bottom':
|
||||
# Try to move top card of tableau column to any foundation
|
||||
col = self.cursor_col
|
||||
if self.game.tableau[col]:
|
||||
for f_idx in range(4):
|
||||
if self.game.can_move_tableau_to_foundation(col, f_idx):
|
||||
success = self.game.move_tableau_to_foundation(col, f_idx)
|
||||
target_found = f_idx
|
||||
# Update cursor position
|
||||
new_len = len(self.game.tableau[col])
|
||||
self.cursor_card_idx = max(0, new_len - 1)
|
||||
break
|
||||
|
||||
if success:
|
||||
self.status_msg = f"Auto-moved card to Foundation {target_found + 1}."
|
||||
if self.game.check_win():
|
||||
self.status_msg = "CONGRATULATIONS! YOU WON THE GAME!"
|
||||
else:
|
||||
self.status_msg = "No valid Foundation move available."
|
||||
|
||||
def draw_card_representation(self, stdscr, y: int, x: int, card: Optional[Card], is_cursor: bool, is_selected: bool) -> None:
|
||||
# Determine bracket symbols
|
||||
left_br, right_br = "[", "]"
|
||||
if is_selected:
|
||||
left_br, right_br = "*", "*"
|
||||
|
||||
# Determine attributes
|
||||
attr = curses.A_NORMAL
|
||||
if is_cursor:
|
||||
attr |= curses.A_REVERSE
|
||||
|
||||
if card is None:
|
||||
# Empty slot indicator
|
||||
color = curses.color_pair(5) # Cyan
|
||||
stdscr.addstr(y, x, f"{left_br} -{right_br}", color | attr)
|
||||
elif not card.is_face_up:
|
||||
# Face down card
|
||||
color = curses.color_pair(2) # White/Standard
|
||||
stdscr.addstr(y, x, f"{left_br}###{right_br}", color | attr)
|
||||
else:
|
||||
# Face up card
|
||||
color = curses.color_pair(1) if card.is_red else curses.color_pair(2)
|
||||
suit_syms = {'H': 'H', 'D': 'D', 'C': 'C', 'S': 'S'}
|
||||
# If terminal supports unicode, we can use symbols
|
||||
try:
|
||||
suit_syms = {'H': '♥', 'D': '♦', 'C': '♣', 'S': '♠'}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
sym = suit_syms.get(card.suit, card.suit)
|
||||
rank_lbl = card.label
|
||||
if len(rank_lbl) == 1:
|
||||
lbl = f" {rank_lbl}{sym}"
|
||||
else:
|
||||
lbl = f"{rank_lbl}{sym}"
|
||||
|
||||
stdscr.addstr(y, x, left_br, attr)
|
||||
stdscr.addstr(y, x + 1, lbl, color | attr)
|
||||
stdscr.addstr(y, x + 4, right_br, attr)
|
||||
|
||||
def draw_screen(self, stdscr) -> None:
|
||||
stdscr.erase()
|
||||
h, w = stdscr.getmaxyx()
|
||||
|
||||
# Check window size
|
||||
if h < 20 or w < 60:
|
||||
stdscr.addstr(0, 0, "Terminal too small. Please enlarge to at least 80x24.", curses.color_pair(1))
|
||||
stdscr.refresh()
|
||||
return
|
||||
|
||||
# Title / Help Banner
|
||||
stdscr.addstr(0, 2, "KLONDIKE SOLITAIRE", curses.color_pair(4) | curses.A_BOLD)
|
||||
help_text = "Arrows/Vim:Move | Space/Enter:Select/Draw | A:Auto-Move | U:Undo | R:Restart | Q:Quit"
|
||||
stdscr.addstr(1, 2, help_text[:w-3], curses.color_pair(3))
|
||||
|
||||
# Top row elements positioning
|
||||
# Stock (col 0), Waste (col 1), gap (col 2), Foundations 0-3 (cols 3-6)
|
||||
x_coords = [2 + i * 8 for i in range(7)]
|
||||
|
||||
# --- Draw STOCK ---
|
||||
is_cursor = (self.cursor_zone == 'top' and self.cursor_col == 0)
|
||||
is_selected = False # Stock can never be selected
|
||||
stdscr.addstr(3, x_coords[0], "STOCK", curses.color_pair(3))
|
||||
|
||||
if self.game.stock:
|
||||
# Top card of Stock is face down
|
||||
self.draw_card_representation(stdscr, 4, x_coords[0], Card('S', 1, False), is_cursor, is_selected)
|
||||
else:
|
||||
self.draw_card_representation(stdscr, 4, x_coords[0], None, is_cursor, is_selected)
|
||||
|
||||
# --- Draw WASTE ---
|
||||
is_cursor = (self.cursor_zone == 'top' and self.cursor_col == 1)
|
||||
is_selected = (self.selected_pos is not None and self.selected_pos[0] == 'top' and self.selected_pos[1] == 1)
|
||||
stdscr.addstr(3, x_coords[1], "WASTE", curses.color_pair(3))
|
||||
|
||||
if self.game.waste:
|
||||
self.draw_card_representation(stdscr, 4, x_coords[1], self.game.waste[-1], is_cursor, is_selected)
|
||||
else:
|
||||
self.draw_card_representation(stdscr, 4, x_coords[1], None, is_cursor, is_selected)
|
||||
|
||||
# --- Draw FOUNDATIONS ---
|
||||
for i in range(4):
|
||||
col_idx = 3 + i
|
||||
is_cursor = (self.cursor_zone == 'top' and self.cursor_col == col_idx)
|
||||
is_selected = (self.selected_pos is not None and self.selected_pos[0] == 'top' and self.selected_pos[1] == col_idx)
|
||||
stdscr.addstr(3, x_coords[col_idx], f"FOUND {i+1}", curses.color_pair(3))
|
||||
|
||||
pile = self.game.foundations[i]
|
||||
if pile:
|
||||
self.draw_card_representation(stdscr, 4, x_coords[col_idx], pile[-1], is_cursor, is_selected)
|
||||
else:
|
||||
self.draw_card_representation(stdscr, 4, x_coords[col_idx], None, is_cursor, is_selected)
|
||||
|
||||
# --- Draw TABLEAU ---
|
||||
stdscr.addstr(6, 2, "TABLEAU COLUMNS:", curses.color_pair(4))
|
||||
for col_idx in range(7):
|
||||
pile = self.game.tableau[col_idx]
|
||||
x = x_coords[col_idx]
|
||||
|
||||
# Label
|
||||
stdscr.addstr(7, x, f"COL {col_idx+1}", curses.color_pair(3))
|
||||
|
||||
if not pile:
|
||||
is_cursor = (self.cursor_zone == 'bottom' and self.cursor_col == col_idx)
|
||||
is_selected = (self.selected_pos is not None and self.selected_pos[0] == 'bottom' and self.selected_pos[1] == col_idx)
|
||||
self.draw_card_representation(stdscr, 8, x, None, is_cursor, is_selected)
|
||||
else:
|
||||
for card_idx, card in enumerate(pile):
|
||||
y = 8 + card_idx
|
||||
# Check cursor highlighting
|
||||
is_cursor = (
|
||||
self.cursor_zone == 'bottom' and
|
||||
self.cursor_col == col_idx and
|
||||
self.cursor_card_idx == card_idx
|
||||
)
|
||||
# Check selection highlighting
|
||||
# If this column is selected as source, we highlight the selected card and all cards below it!
|
||||
is_selected = False
|
||||
if self.selected_pos is not None:
|
||||
src_zone, src_col, src_card_idx = self.selected_pos
|
||||
if src_zone == 'bottom' and src_col == col_idx and card_idx >= src_card_idx:
|
||||
is_selected = True
|
||||
|
||||
self.draw_card_representation(stdscr, y, x, card, is_cursor, is_selected)
|
||||
|
||||
# Draw Status Bar at bottom
|
||||
stdscr.addstr(h - 2, 2, f"Status: {self.status_msg}"[:w-3], curses.color_pair(4) | curses.A_BOLD)
|
||||
|
||||
# Draw game seed info or undo history count
|
||||
info_str = f"Undos available: {len(self.game.history)}"
|
||||
stdscr.addstr(h - 2, w - len(info_str) - 3, info_str, curses.color_pair(3))
|
||||
|
||||
stdscr.refresh()
|
||||
|
||||
def run(self, stdscr) -> None:
|
||||
# Initialize color scheme
|
||||
if curses.has_colors():
|
||||
curses.start_color()
|
||||
curses.init_pair(1, curses.COLOR_RED, curses.COLOR_BLACK) # Hearts/Diamonds
|
||||
curses.init_pair(2, curses.COLOR_WHITE, curses.COLOR_BLACK) # Spades/Clubs
|
||||
curses.init_pair(3, curses.COLOR_YELLOW, curses.COLOR_BLACK) # Navigation/Headers
|
||||
curses.init_pair(4, curses.COLOR_GREEN, curses.COLOR_BLACK) # Success/Banners
|
||||
curses.init_pair(5, curses.COLOR_CYAN, curses.COLOR_BLACK) # Empty Slots
|
||||
|
||||
curses.curs_set(0)
|
||||
stdscr.keypad(True)
|
||||
|
||||
while True:
|
||||
self.draw_screen(stdscr)
|
||||
try:
|
||||
ch = stdscr.getch()
|
||||
except KeyboardInterrupt:
|
||||
break
|
||||
|
||||
if ch == -1:
|
||||
continue
|
||||
|
||||
# Handle Quit
|
||||
if ch in (ord('q'), ord('Q')):
|
||||
break
|
||||
|
||||
# Handle Movement
|
||||
elif ch == curses.KEY_LEFT or ch == ord('h'):
|
||||
self.move_cursor('left')
|
||||
elif ch == curses.KEY_RIGHT or ch == ord('l'):
|
||||
self.move_cursor('right')
|
||||
elif ch == curses.KEY_UP or ch == ord('k'):
|
||||
self.move_cursor('up')
|
||||
elif ch == curses.KEY_DOWN or ch == ord('j'):
|
||||
self.move_cursor('down')
|
||||
|
||||
# Handle Selection / Draw / Move
|
||||
elif ch in (ord(' '), 10, 13, curses.KEY_ENTER): # Space, Enter, Return
|
||||
self.handle_action()
|
||||
|
||||
# Handle Cancel selection
|
||||
elif ch in (27, ord('c'), ord('C')): # Escape, 'c', 'C'
|
||||
self.selected_pos = None
|
||||
self.status_msg = "Selection cancelled."
|
||||
|
||||
# Handle Auto-move
|
||||
elif ch in (ord('a'), ord('A')):
|
||||
self.handle_auto_move()
|
||||
|
||||
# Handle Undo
|
||||
elif ch in (ord('u'), ord('U')):
|
||||
if self.game.undo():
|
||||
self.status_msg = "Last move undone."
|
||||
self.selected_pos = None
|
||||
# Make sure cursor positions are validated against new column lens
|
||||
if self.cursor_zone == 'bottom':
|
||||
pile_len = len(self.game.tableau[self.cursor_col])
|
||||
self.cursor_card_idx = min(self.cursor_card_idx, max(0, pile_len - 1))
|
||||
else:
|
||||
self.status_msg = "Nothing to undo."
|
||||
|
||||
# Handle Restart
|
||||
elif ch in (ord('r'), ord('R')):
|
||||
self.game.deal()
|
||||
self.cursor_zone = 'top'
|
||||
self.cursor_col = 0
|
||||
self.cursor_card_idx = 0
|
||||
self.selected_pos = None
|
||||
self.status_msg = "New game started!"
|
||||
|
||||
# Handle Resize
|
||||
elif ch == curses.KEY_RESIZE:
|
||||
stdscr.clear()
|
||||
stdscr.refresh()
|
||||
3
status.json
Normal file
3
status.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"outcome": "succeeded"
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue