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>
196 lines
7 KiB
Swift
196 lines
7 KiB
Swift
import Foundation
|
|
|
|
struct SpiderRules: GameRules {
|
|
let variant: GameVariant = .spider
|
|
|
|
func deal(deck: [Card]) -> GameSnapshot {
|
|
var remaining = deck
|
|
var tableaus: [[Card]] = []
|
|
|
|
// Deal 10 tableaus: first 4 get 6 cards, last 6 get 5 cards, top face-up
|
|
for i in 0..<10 {
|
|
let cardCount = i < 4 ? 6 : 5
|
|
var column: [Card] = []
|
|
for j in 0..<cardCount {
|
|
var card = remaining.removeFirst()
|
|
card.isFaceUp = (j == cardCount - 1)
|
|
column.append(card)
|
|
}
|
|
tableaus.append(column)
|
|
}
|
|
|
|
return GameSnapshot(
|
|
tableaus: tableaus,
|
|
foundations: Array(repeating: [], count: 8),
|
|
stock: remaining,
|
|
waste: [],
|
|
freeCells: [],
|
|
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 .tableau(let index):
|
|
guard index >= 0, index < state.tableaus.count else { return false }
|
|
let tableau = state.tableaus[index]
|
|
if tableau.isEmpty { return true }
|
|
guard let topCard = tableau.last, topCard.isFaceUp else { return false }
|
|
return MoveValidator.isDescending(firstCard, onto: topCard)
|
|
|
|
case .foundation:
|
|
// Foundations are auto-filled when a complete K→A same-suit sequence is formed
|
|
return false
|
|
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func drawFromStock(state: inout GameSnapshot, drawCount: Int) -> MoveAction? {
|
|
// Spider deals one card to each tableau (must have at least one card per tableau)
|
|
guard !state.stock.isEmpty else { return nil }
|
|
guard state.tableaus.allSatisfy({ !$0.isEmpty }) else { return nil }
|
|
|
|
var drawn: [Card] = []
|
|
let dealCount = min(state.tableaus.count, state.stock.count)
|
|
for i in 0..<dealCount {
|
|
var card = state.stock.removeLast()
|
|
card.isFaceUp = true
|
|
state.tableaus[i].append(card)
|
|
drawn.append(card)
|
|
}
|
|
|
|
return MoveAction(cards: drawn, from: .stock, to: .waste, didFlipCard: true, scoreChange: 0)
|
|
}
|
|
|
|
func scoreForMove(from: CardLocation, to: CardLocation) -> Int {
|
|
switch (from, to) {
|
|
case (.tableau, .foundation): return 100
|
|
case (.tableau, .tableau): return 1
|
|
default: return 0
|
|
}
|
|
}
|
|
|
|
func isWon(state: GameSnapshot) -> Bool {
|
|
state.foundations.allSatisfy { $0.count == 13 }
|
|
}
|
|
|
|
func canAutoComplete(state: GameSnapshot) -> Bool {
|
|
guard state.stock.isEmpty else { return false }
|
|
return state.tableaus.allSatisfy { column in
|
|
column.allSatisfy { $0.isFaceUp }
|
|
}
|
|
}
|
|
|
|
/// Check if the top cards of a tableau form a complete K→A same-suit sequence.
|
|
func isCompleteSequence(in tableau: [Card]) -> Bool {
|
|
guard tableau.count >= 13 else { return false }
|
|
let sequence = tableau.suffix(13)
|
|
let suit = sequence.first!.suit
|
|
|
|
for (offset, card) in sequence.enumerated() {
|
|
guard card.isFaceUp,
|
|
card.suit == suit,
|
|
card.rank.rawValue == 13 - offset else {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
/// Check all tableaus for complete sequences and move them to foundations.
|
|
func checkAndMoveCompleteSequences(state: inout GameSnapshot) -> Bool {
|
|
var foundComplete = false
|
|
for tabIndex in 0..<state.tableaus.count {
|
|
if isCompleteSequence(in: state.tableaus[tabIndex]) {
|
|
let sequence = Array(state.tableaus[tabIndex].suffix(13))
|
|
state.tableaus[tabIndex].removeLast(13)
|
|
// Find first empty foundation
|
|
if let fIndex = state.foundations.firstIndex(where: { $0.isEmpty }) {
|
|
state.foundations[fIndex] = sequence
|
|
foundComplete = true
|
|
}
|
|
// Flip new top card
|
|
if let lastIndex = state.tableaus[tabIndex].indices.last,
|
|
!state.tableaus[tabIndex][lastIndex].isFaceUp {
|
|
state.tableaus[tabIndex][lastIndex].isFaceUp = true
|
|
}
|
|
}
|
|
}
|
|
return foundComplete
|
|
}
|
|
|
|
func findHints(state: GameSnapshot, settings: DifficultySettings) -> [HintResult] {
|
|
guard settings.hintsEnabled else { return [] }
|
|
var hints: [HintResult] = []
|
|
|
|
for (tabIndex, tableau) in state.tableaus.enumerated() {
|
|
let faceUpRun = sameSuitDescendingRun(in: tableau)
|
|
guard !faceUpRun.isEmpty else { continue }
|
|
|
|
for (destIndex, _) in state.tableaus.enumerated() {
|
|
guard destIndex != tabIndex else { continue }
|
|
if canMove(cards: faceUpRun, from: .tableau(tabIndex), to: .tableau(destIndex), state: state) {
|
|
let priority = (tableau.count - faceUpRun.count > 0 &&
|
|
!tableau[tableau.count - faceUpRun.count - 1].isFaceUp) ? 2 : 4
|
|
hints.append(HintResult(cards: faceUpRun, from: .tableau(tabIndex), to: .tableau(destIndex), priority: priority))
|
|
}
|
|
}
|
|
}
|
|
|
|
if !state.stock.isEmpty {
|
|
hints.append(HintResult(cards: [], from: .stock, to: .waste, priority: 5))
|
|
}
|
|
|
|
return hints.sorted { $0.priority < $1.priority }
|
|
}
|
|
|
|
func canStackOnTableau(card: Card, onto target: Card) -> Bool {
|
|
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 }
|
|
guard cards.allSatisfy({ $0.isFaceUp }) else { return false }
|
|
// Must be a same-suit descending sequence
|
|
for i in 1..<cards.count {
|
|
guard MoveValidator.isSameSuit(cards[i], as: cards[i - 1]),
|
|
MoveValidator.isDescending(cards[i], onto: cards[i - 1]) else {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
/// Returns the longest same-suit descending run from the bottom of the face-up portion.
|
|
private func sameSuitDescendingRun(in tableau: [Card]) -> [Card] {
|
|
let faceUp = Array(tableau.trailingSuffix(while: { $0.isFaceUp }))
|
|
guard !faceUp.isEmpty else { return [] }
|
|
|
|
var run = [faceUp.last!]
|
|
for i in stride(from: faceUp.count - 2, through: 0, by: -1) {
|
|
let card = faceUp[i]
|
|
guard MoveValidator.isSameSuit(card, as: run[0]),
|
|
card.rank.rawValue == run[0].rank.rawValue + 1 else {
|
|
break
|
|
}
|
|
run.insert(card, at: 0)
|
|
}
|
|
return run
|
|
}
|
|
}
|