301 lines
11 KiB
Swift
301 lines
11 KiB
Swift
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..<length {
|
|
result.append(alphabet[Int.random(in: 0..<alphabet.count)])
|
|
}
|
|
return result
|
|
}
|
|
}
|