import Foundation enum GameStatus: Equatable { case playing case solved case error } @MainActor final class ShikakuGame: ObservableObject { static let supportedSizes = [5, 7, 10, 15, 20, 25] @Published private(set) var puzzle: ShikakuPuzzle @Published private(set) var playerRects: [PlayerRect] = [] @Published private(set) var errorRectIDs: Set = [] @Published private(set) var message = "Ready" @Published private(set) var status: GameStatus = .playing private var startDate = Date() private var solvedElapsed: TimeInterval? var size: Int { puzzle.size } var currentSeed: UInt64 { puzzle.seed } init(size: Int = 5) { self.puzzle = Self.makePuzzle(size: size) self.startDate = Date() } func newPuzzle(size requestedSize: Int? = nil, seed requestedSeed: UInt64? = nil) { let nextSize = requestedSize ?? size puzzle = Self.makePuzzle(size: nextSize, seed: requestedSeed) playerRects = [] errorRectIDs = [] message = "Ready" status = .playing startDate = Date() solvedElapsed = nil } func clear() { playerRects = [] errorRectIDs = [] message = "Cleared" status = .playing if let solvedElapsed { startDate = Date().addingTimeInterval(-solvedElapsed) } solvedElapsed = nil } func placeRect(_ rect: PlayerRect) { guard rect.area > 1 else { errorRectIDs = [] status = .playing message = "Drag over at least 2 cells" return } let newCells = Set(rect.cells) playerRects.removeAll { !Set($0.cells).isDisjoint(with: newCells) } playerRects.append(rect) errorRectIDs = [] status = .playing runAutoCheck() } func removeRect(at cell: GridPoint) { let oldCount = playerRects.count playerRects.removeAll { $0.contains(cell) } if playerRects.count != oldCount { errorRectIDs = [] status = .playing message = "Ready" solvedElapsed = nil } } func checkSolution() { let result = ShikakuValidation.verify(size: size, playerRects: playerRects, clues: puzzle.clues) if result.isSolved { markSolved() errorRectIDs = [] message = "Solved in \(formattedElapsed())" } else { status = .error errorRectIDs = invalidRectIDs() message = result.message } } func formattedElapsed(at date: Date = Date()) -> String { let elapsed = solvedElapsed ?? max(0, date.timeIntervalSince(startDate)) let minutes = Int(elapsed) / 60 let seconds = Int(elapsed) % 60 return String(format: "%d:%02d", minutes, seconds) } private func runAutoCheck() { guard status != .solved else { return } let result = ShikakuValidation.verify(size: size, playerRects: playerRects, clues: puzzle.clues) if result.isSolved { markSolved() message = "Solved in \(formattedElapsed())" } else { status = .playing message = "Ready" } } private func markSolved() { if solvedElapsed == nil { solvedElapsed = Date().timeIntervalSince(startDate) } status = .solved } private func invalidRectIDs() -> Set { Set(playerRects.compactMap { rect in let clueValues = rect.cells.compactMap { puzzle.clues[$0] } if clueValues.count != 1 || clueValues.first != rect.area { return rect.id } return nil }) } private static func makePuzzle(size: Int, seed requestedSeed: UInt64? = nil) -> ShikakuPuzzle { do { return try ShikakuGenerator.generate(size: size, seed: requestedSeed) } catch { assertionFailure("Falling back after puzzle generation error: \(error)") return fallbackPuzzle(size: size) } } private static func fallbackPuzzle(size: Int) -> ShikakuPuzzle { let rect = SolutionRect(row: 0, col: 0, height: size, width: size) let clue = GridPoint(row: size / 2, col: size / 2) return ShikakuPuzzle(size: size, clues: [clue: rect.area], solution: [rect], seed: 0) } }