Complete native rewrite of the web-based SoliCards game as a SwiftUI multiplatform app targeting iOS 17+, iPadOS 17+, and macOS 14+. Three solitaire variants (Klondike, Spider, FreeCell) with full game rules, drag & drop, smart zoom layout, 6 themes, 4 difficulty levels, SwiftData persistence, VoiceOver accessibility, and 57 unit tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
216 lines
9 KiB
Swift
216 lines
9 KiB
Swift
import Foundation
|
||
|
||
struct FreeCellRules: GameRules {
|
||
let variant: GameVariant = .freeCell
|
||
|
||
func deal(deck: [Card]) -> GameSnapshot {
|
||
var remaining = deck
|
||
var tableaus: [[Card]] = []
|
||
|
||
// Deal all 52 cards to 8 tableaus, all face-up
|
||
// First 4 tableaus get 7 cards, last 4 get 6 cards
|
||
for i in 0..<8 {
|
||
let cardCount = i < 4 ? 7 : 6
|
||
var column: [Card] = []
|
||
for _ in 0..<cardCount {
|
||
var card = remaining.removeFirst()
|
||
card.isFaceUp = true
|
||
column.append(card)
|
||
}
|
||
tableaus.append(column)
|
||
}
|
||
|
||
return GameSnapshot(
|
||
tableaus: tableaus,
|
||
foundations: [[], [], [], []],
|
||
stock: [],
|
||
waste: [],
|
||
freeCells: [nil, nil, nil, nil],
|
||
moves: 0,
|
||
score: 0
|
||
)
|
||
}
|
||
|
||
func canMove(cards: [Card], from: CardLocation, to: CardLocation, state: GameSnapshot) -> Bool {
|
||
guard let firstCard = cards.first else { return false }
|
||
|
||
switch to {
|
||
case .foundation(let index):
|
||
guard cards.count == 1 else { return false }
|
||
guard index >= 0, index < state.foundations.count else { return false }
|
||
return MoveValidator.canPlaceOnFoundation(firstCard, topCard: state.foundations[index].last)
|
||
|
||
case .tableau(let index):
|
||
guard index >= 0, index < state.tableaus.count else { return false }
|
||
let maxMovable = calculateMaxMovableCards(state: state, targetEmpty: state.tableaus[index].isEmpty)
|
||
guard cards.count <= maxMovable else { return false }
|
||
|
||
let tableau = state.tableaus[index]
|
||
if tableau.isEmpty { return true }
|
||
guard let topCard = tableau.last else { return false }
|
||
return MoveValidator.isAlternatingColor(firstCard, with: topCard)
|
||
&& MoveValidator.isDescending(firstCard, onto: topCard)
|
||
|
||
case .freeCell(let index):
|
||
guard cards.count == 1 else { return false }
|
||
guard index >= 0, index < state.freeCells.count else { return false }
|
||
return state.freeCells[index] == nil
|
||
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func drawFromStock(state: inout GameSnapshot, drawCount: Int) -> MoveAction? {
|
||
nil // FreeCell has no stock
|
||
}
|
||
|
||
func scoreForMove(from: CardLocation, to: CardLocation) -> Int {
|
||
switch (from, to) {
|
||
case (_, .foundation): return 10
|
||
case (.freeCell, .tableau): return 5
|
||
default: return 0
|
||
}
|
||
}
|
||
|
||
func isWon(state: GameSnapshot) -> Bool {
|
||
state.foundations.allSatisfy { $0.count == 13 }
|
||
}
|
||
|
||
func canAutoComplete(state: GameSnapshot) -> Bool {
|
||
// All tableau cards must be face-up and in descending order
|
||
let allFaceUp = state.tableaus.allSatisfy { $0.allSatisfy { $0.isFaceUp } }
|
||
guard allFaceUp else { return false }
|
||
|
||
// Check that all tableaus are in valid descending alternating-color order
|
||
for tableau in state.tableaus {
|
||
for i in 1..<tableau.count {
|
||
guard MoveValidator.isAlternatingColor(tableau[i], with: tableau[i - 1]),
|
||
MoveValidator.isDescending(tableau[i], onto: tableau[i - 1]) else {
|
||
return false
|
||
}
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
/// Power move formula: (1 + empty_freecells) × 2^empty_tableaus
|
||
func calculateMaxMovableCards(state: GameSnapshot, targetEmpty: Bool) -> Int {
|
||
let emptyFreeCells = state.freeCells.filter { $0 == nil }.count
|
||
// Don't count the target tableau as empty if we're moving to an empty tableau
|
||
let emptyTableaus = state.tableaus.filter { $0.isEmpty }.count - (targetEmpty ? 1 : 0)
|
||
let adjustedEmptyTableaus = max(0, emptyTableaus)
|
||
return (1 + emptyFreeCells) * Int(pow(2.0, Double(adjustedEmptyTableaus)))
|
||
}
|
||
|
||
func findHints(state: GameSnapshot, settings: DifficultySettings) -> [HintResult] {
|
||
guard settings.hintsEnabled else { return [] }
|
||
var hints: [HintResult] = []
|
||
|
||
// Priority 1: Aces to foundation (from tableaus and free cells)
|
||
for (tabIndex, tableau) in state.tableaus.enumerated() {
|
||
guard let topCard = tableau.last, MoveValidator.isAce(topCard) else { continue }
|
||
for (fIndex, foundation) in state.foundations.enumerated() {
|
||
if MoveValidator.canPlaceOnFoundation(topCard, topCard: foundation.last) {
|
||
hints.append(HintResult(cards: [topCard], from: .tableau(tabIndex), to: .foundation(fIndex), priority: 1))
|
||
}
|
||
}
|
||
}
|
||
|
||
for (cellIndex, cell) in state.freeCells.enumerated() {
|
||
guard let card = cell, MoveValidator.isAce(card) else { continue }
|
||
for (fIndex, foundation) in state.foundations.enumerated() {
|
||
if MoveValidator.canPlaceOnFoundation(card, topCard: foundation.last) {
|
||
hints.append(HintResult(cards: [card], from: .freeCell(cellIndex), to: .foundation(fIndex), priority: 1))
|
||
}
|
||
}
|
||
}
|
||
|
||
// Priority 2: Other foundation moves
|
||
for (tabIndex, tableau) in state.tableaus.enumerated() {
|
||
guard let topCard = tableau.last, !MoveValidator.isAce(topCard) else { continue }
|
||
for (fIndex, foundation) in state.foundations.enumerated() {
|
||
if MoveValidator.canPlaceOnFoundation(topCard, topCard: foundation.last) {
|
||
hints.append(HintResult(cards: [topCard], from: .tableau(tabIndex), to: .foundation(fIndex), priority: 2))
|
||
}
|
||
}
|
||
}
|
||
|
||
for (cellIndex, cell) in state.freeCells.enumerated() {
|
||
guard let card = cell, !MoveValidator.isAce(card) else { continue }
|
||
for (fIndex, foundation) in state.foundations.enumerated() {
|
||
if MoveValidator.canPlaceOnFoundation(card, topCard: foundation.last) {
|
||
hints.append(HintResult(cards: [card], from: .freeCell(cellIndex), to: .foundation(fIndex), priority: 2))
|
||
}
|
||
}
|
||
}
|
||
|
||
// Priority 3: Tableau to tableau moves
|
||
for (tabIndex, tableau) in state.tableaus.enumerated() {
|
||
guard let topCard = tableau.last else { continue }
|
||
for (destIndex, _) in state.tableaus.enumerated() {
|
||
guard destIndex != tabIndex else { continue }
|
||
if canMove(cards: [topCard], from: .tableau(tabIndex), to: .tableau(destIndex), state: state) {
|
||
hints.append(HintResult(cards: [topCard], from: .tableau(tabIndex), to: .tableau(destIndex), priority: 3))
|
||
}
|
||
}
|
||
}
|
||
|
||
// Priority 4: Free cell to tableau
|
||
for (cellIndex, cell) in state.freeCells.enumerated() {
|
||
guard let card = cell else { continue }
|
||
for (tabIndex, _) in state.tableaus.enumerated() {
|
||
if canMove(cards: [card], from: .freeCell(cellIndex), to: .tableau(tabIndex), state: state) {
|
||
hints.append(HintResult(cards: [card], from: .freeCell(cellIndex), to: .tableau(tabIndex), priority: 4))
|
||
}
|
||
}
|
||
}
|
||
|
||
// Priority 5: Move to free cell
|
||
if state.freeCells.contains(where: { $0 == nil }) {
|
||
for (tabIndex, tableau) in state.tableaus.enumerated() {
|
||
guard let topCard = tableau.last else { continue }
|
||
if let emptyCell = state.freeCells.firstIndex(where: { $0 == nil }) {
|
||
hints.append(HintResult(cards: [topCard], from: .tableau(tabIndex), to: .freeCell(emptyCell), priority: 5))
|
||
}
|
||
}
|
||
}
|
||
|
||
return hints.sorted { $0.priority < $1.priority }
|
||
}
|
||
|
||
func canStackOnTableau(card: Card, onto target: Card) -> Bool {
|
||
MoveValidator.isAlternatingColor(card, with: target)
|
||
&& MoveValidator.isDescending(card, onto: target)
|
||
}
|
||
|
||
func canPickUp(cards: [Card], from: CardLocation, state: GameSnapshot) -> Bool {
|
||
guard !cards.isEmpty else { return false }
|
||
|
||
switch from {
|
||
case .tableau(let index):
|
||
guard index >= 0, index < state.tableaus.count else { return false }
|
||
// All cards must be face-up and form a valid alternating-color descending sequence
|
||
guard cards.allSatisfy({ $0.isFaceUp }) else { return false }
|
||
for i in 1..<cards.count {
|
||
guard MoveValidator.isAlternatingColor(cards[i], with: cards[i - 1]),
|
||
MoveValidator.isDescending(cards[i], onto: cards[i - 1]) else {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
|
||
case .freeCell(let index):
|
||
guard cards.count == 1, index >= 0, index < state.freeCells.count else { return false }
|
||
return state.freeCells[index] == cards.first
|
||
|
||
case .foundation(let index):
|
||
guard cards.count == 1, index >= 0, index < state.foundations.count else { return false }
|
||
return cards[0] == state.foundations[index].last
|
||
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
}
|