SoliCards-iOS-iPadOS-MacOS/SoliCards/GameEngine/FreeCellRules.swift
idev2025 de0da01f25 feat: SoliCards v1.2.0 — native SwiftUI solitaire for iOS, iPadOS, macOS
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>
2026-04-14 08:26:14 -04:00

216 lines
9 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}
}
}