import Foundation enum GolfAPIError: LocalizedError { case notLoggedIn case http(Int, String) case decoding case noGameID case permanentTokenFailed var errorDescription: String? { switch self { case .notLoggedIn: return "Log in to play." case .http(let code, let body): return "Server error (\(code)): \(body)" case .decoding: return "Could not read server response." case .noGameID: return "Server did not return a game id." case .permanentTokenFailed: return "Could not create a permanent login key." } } } struct GolfAPISnapshot: Equatable { struct Rules: Equatable { var cardNumber: Int var pointsToEnd: Int } struct Card: Equatable { var card: Int var cardPlacement: Int } struct Player: Equatable, Identifiable { var id: String { user } var user: String var points: Int var orderID: Int var lastMode: String var multiplier: Int var cards: [Card] var currentGamePoints: Int } var rules: Rules var currentPlayer: Int var discard: [Int] var deckSize: Int var players: [Player] var action: String var discardTop: Int? { discard.last } func player(named username: String) -> Player? { players.first { $0.user == username } } } actor GolfAPI { static let shared = GolfAPI() static let baseURL = URL(string: "https://www.lschaefer.xyz")! private static let sessionKeyAccount = "sessionKey" private static let usernameAccount = "username" private var sessionKey: String? { get { KeychainStore.string(forKey: Self.sessionKeyAccount) } set { KeychainStore.set(newValue, forKey: Self.sessionKeyAccount) } } private(set) var username: String? { get { KeychainStore.string(forKey: Self.usernameAccount) } set { KeychainStore.set(newValue, forKey: Self.usernameAccount) } } var isLoggedIn: Bool { sessionKey?.isEmpty == false } func currentUsername() -> String? { username } func setSessionKey(_ key: String, username: String) { sessionKey = key self.username = username } /// Exchanges the short-lived web login cookie for a non-expiring API key (`expire = 0` on `/api/key.php`). func requestPermanentToken() async throws -> String { let data = try await post([.init(name: "create", value: "0")], path: "api/key.php") guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let cookie = obj["cookie"] as? String, !cookie.isEmpty else { throw GolfAPIError.permanentTokenFailed } return cookie } func logout() { sessionKey = nil username = nil } func deleteAccount(username: String) async throws { _ = try await post([ .init(name: "type", value: "delete"), .init(name: "user", value: username) ], path: "api/user.php") logout() } func createGame( name: String, pointsToEnd: Int, playersToStart: Int, bots: Int, decks: Int, password: String ) async throws -> Int { let data = try await post([ .init(name: "create", value: name), .init(name: "cardNumber", value: "6"), .init(name: "flipNumber", value: "2"), .init(name: "playersToStart", value: "\(playersToStart)"), .init(name: "bots", value: "\(max(0, bots))"), .init(name: "multiplierForFlip", value: "2"), .init(name: "pointsToEnd", value: "\(pointsToEnd)"), .init(name: "decks", value: "\(max(1, decks))"), .init(name: "skipTime", value: "0"), .init(name: "skipTurns", value: "0"), .init(name: "resetPoints", value: "0"), .init(name: "password", value: password) ]) guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw GolfAPIError.noGameID } if let id = obj["id"] as? Int { return id } if let id = obj["id"] as? NSNumber { return id.intValue } throw GolfAPIError.noGameID } func join(gameID: Int, password: String?) async throws { do { var items = [URLQueryItem(name: "join", value: "\(gameID)")] if let password, !password.isEmpty { items.append(.init(name: "password", value: password)) } _ = try await post(items) } catch { if case GolfAPIError.http(let code, let body) = error, code == 409 || body.lowercased().contains("already joined") { return } if error.localizedDescription.lowercased().contains("already joined") { return } throw error } } func forceUpdate(gameID: Int) async throws { _ = try await get(queryItems: [.init(name: "forceUpdate", value: "\(gameID)")]) } func update(gameID: Int) async throws -> GolfAPISnapshot? { let (data, code) = try await getRaw(queryItems: [.init(name: "update", value: "\(gameID)")]) if code == 304 { return nil } let text = String(data: data, encoding: .utf8) ?? "" if text == "[]" || text.isEmpty || text == "No change" { return nil } return try decodeSnapshot(data) } func swap(gameID: Int, slot: Int, source: SwapSource) async throws { _ = try await post([ .init(name: "swap", value: "\(slot + 1)"), .init(name: "swap2", value: source == .deck ? "deck" : "discard"), .init(name: "game", value: "\(gameID)") ]) } private func requireKey() throws -> String { guard let key = sessionKey, !key.isEmpty else { throw GolfAPIError.notLoggedIn } return key } private func post(_ items: [URLQueryItem], path: String = "api/golf.php") async throws -> Data { let key = try requireKey() var components = URLComponents() components.queryItems = items var request = URLRequest(url: Self.baseURL.appendingPathComponent(path)) request.httpMethod = "POST" request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") request.setValue(key, forHTTPHeaderField: "Authorization") request.httpBody = components.query?.data(using: .utf8) let (data, response) = try await URLSession.shared.data(for: request) let code = (response as? HTTPURLResponse)?.statusCode ?? 0 let text = String(data: data, encoding: .utf8) ?? "" guard (200..<300).contains(code) else { throw GolfAPIError.http(code, text) } return data } private func get(queryItems: [URLQueryItem]) async throws -> Data { let (data, code) = try await getRaw(queryItems: queryItems) guard (200..<300).contains(code) else { throw GolfAPIError.http(code, String(data: data, encoding: .utf8) ?? "") } return data } private func getRaw(queryItems: [URLQueryItem]) async throws -> (Data, Int) { let key = try requireKey() var components = URLComponents( url: Self.baseURL.appendingPathComponent("api/golf.php"), resolvingAgainstBaseURL: false )! components.queryItems = queryItems var request = URLRequest(url: components.url!) request.setValue(key, forHTTPHeaderField: "Authorization") let (data, response) = try await URLSession.shared.data(for: request) return (data, (response as? HTTPURLResponse)?.statusCode ?? 0) } private func decodeSnapshot(_ data: Data) throws -> GolfAPISnapshot { guard let root = try JSONSerialization.jsonObject(with: data) as? [String: Any], let rulesObj = root["rules"] as? [String: Any] else { throw GolfAPIError.decoding } let discard = (root["discard"] as? [Any])?.compactMap { intValue(optional: $0) } ?? [] let playersJSON = root["players"] as? [[String: Any]] ?? [] let players: [GolfAPISnapshot.Player] = playersJSON.map { p in let cards = (p["cards"] as? [[String: Any]] ?? []).map { GolfAPISnapshot.Card( card: intValue($0["card"]), cardPlacement: intValue($0["cardPlacement"]) ) } return GolfAPISnapshot.Player( user: p["user"] as? String ?? "", points: intValue(p["points"]), orderID: intValue(p["orderID"]), lastMode: p["lastMode"] as? String ?? "", multiplier: intValue(p["multiplier"]), cards: cards, currentGamePoints: intValue(p["currentGamePoints"]) ) } return GolfAPISnapshot( rules: GolfAPISnapshot.Rules( cardNumber: intValue(rulesObj["cardNumber"]), pointsToEnd: intValue(rulesObj["pointsToEnd"]) ), currentPlayer: intValue(root["currentPlayer"]), discard: discard, deckSize: intValue(root["deckSize"]), players: players, action: root["action"] as? String ?? "" ) } private func intValue(_ any: Any?) -> Int { intValue(optional: any) ?? 0 } private func intValue(optional any: Any?) -> Int? { if let i = any as? Int { return i } if let n = any as? NSNumber { return n.intValue } if let s = any as? String { return Int(s) } return nil } } struct MessageGameRef: Equatable { var gameID: Int var password: String? } enum MessageGameID { static let base = URL(string: "https://www.lschaefer.xyz/golf/game.php")! static func url(gameID: Int, password: String?) -> URL { var components = URLComponents(url: base, resolvingAgainstBaseURL: false)! components.queryItems = [URLQueryItem(name: "game", value: "\(gameID)")] if let password, !password.isEmpty { components.fragment = password } return components.url! } static func parse(from url: URL?) -> MessageGameRef? { guard let url, let components = URLComponents(url: url, resolvingAgainstBaseURL: false), let game = components.queryItems?.first(where: { $0.name == "game" })?.value, let gameID = Int(game) else { return nil } let password = components.fragment.flatMap { $0.isEmpty ? nil : $0 } return MessageGameRef(gameID: gameID, password: password) } /// Alphanumeric only — safe for URL fragments and the server's SQL-quoted password field. static func randomPassword(length: Int = 12) -> String { let alphabet = Array("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") var result = "" result.reserveCapacity(length) for _ in 0..