XCode-Claude-Workflow/SoliCards/GameEngine/SpiderRules.swift
idev2025 0f989f5c86 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.

Key features:
- MVVM + Protocol-Oriented Strategy architecture
- DragGesture with coordinate-space hit-testing (long press + drag)
- Smart zoom: cards auto-size to fit screen based on deepest column
- Landscape: 30% bigger cards with scrollable overflow (iOS)
- macOS: 120pt card cap, 92% height buffer for window resizing
- Auto-save, game resume, statistics tracking via SwiftData
- Privacy manifest, app icon, String Catalog, zero dependencies

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 07:33:52 -04:00

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