import Combine import Foundation import WebKit enum PresentationChrome: Equatable { case compact case expanded } enum GameShareMode: Equatable { /// Insert / update an iMessage bubble. case messagesBubble /// Present the system share sheet / ShareLink. case systemShare } @MainActor final class GameController: ObservableObject { @Published var presentationStyle: PresentationChrome = .compact @Published var gameID: Int? @Published var gamePassword: String? @Published var snapshot: GolfAPISnapshot? @Published var isLoggedIn = false @Published var username = "" @Published var draftPointsToEnd = 100 @Published var draftPlayersToStart = 2 @Published var draftBots = 0 @Published var draftDecks = 1 @Published var errorMessage: String? @Published var statusMessage: String? @Published var selectedSlot: Int? @Published var selectedSource: SwapSource? @Published var isBusy = false var shareMode: GameShareMode = .messagesBubble private var pollTask: Task? /// Bumped when applying a local action so in-flight polls cannot overwrite newer board state. private var snapshotGeneration = 0 var onRequestExpand: (() -> Void)? var onRequestCollapse: (() -> Void)? /// gameID, password, caption, isNewSession var onShareGame: ((Int, String?, String, Bool) -> Void)? var isMyTurn: Bool { snapshot?.action == "switch" } var isWaitingForPlayers: Bool { gameID != nil && snapshot == nil && errorMessage == nil } var isRoundOver: Bool { snapshot?.action == "roundOver" } var isEliminated: Bool { snapshot?.action == "eliminated" } var joinURL: URL? { guard let gameID else { return nil } return MessageGameID.url(gameID: gameID, password: gamePassword) } var myPlayer: GolfAPISnapshot.Player? { let name = username.trimmingCharacters(in: .whitespacesAndNewlines) guard !name.isEmpty else { return nil } return snapshot?.player(named: name) } func configureStandalone() { presentationStyle = .expanded shareMode = .systemShare Task { await refreshAuth() } } func configureMessages( style: PresentationChrome, remoteParticipantCount: Int, gameURL: URL? ) { presentationStyle = style shareMode = .messagesBubble Task { await refreshAuth() } let chatSize = 1 + remoteParticipantCount draftPlayersToStart = min(12, chatSize) draftDecks = Int(ceil(Double(chatSize) / 3.0)) openFromURL(gameURL) } func openFromURL(_ url: URL?) { let ref = MessageGameID.parse(from: url) selectedSlot = nil selectedSource = nil errorMessage = nil if ref?.gameID != gameID { snapshot = nil } gameID = ref?.gameID gamePassword = ref?.password if let id = ref?.gameID { Task { await openGame(id: id) } } else { stopPolling() } } func refreshAuth() async { isLoggedIn = await GolfAPI.shared.isLoggedIn username = await GolfAPI.shared.currentUsername() ?? "" } func completeWebLogin(sessionKey: String, username: String) { errorMessage = nil statusMessage = "Creating permanent login key…" let user = username.trimmingCharacters(in: .whitespacesAndNewlines) let key = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines) guard !user.isEmpty, !key.isEmpty else { errorMessage = "Login did not produce a session." statusMessage = nil return } Task { await GolfAPI.shared.setSessionKey(key, username: user) do { let permanentKey = try await GolfAPI.shared.requestPermanentToken() await GolfAPI.shared.setSessionKey(permanentKey, username: user) statusMessage = "Logged in as \(user)" } catch { errorMessage = error.localizedDescription statusMessage = "Logged in as \(user) (temporary session)" } await refreshAuth() if let gameID { await openGame(id: gameID) } } } func logout() { stopPolling() Task { await GolfAPI.shared.logout() await clearWebsiteCookies() await refreshAuth() snapshot = nil statusMessage = "Logged out" } } func deleteAccount(confirmation: String) { errorMessage = nil let confirmed = confirmation.trimmingCharacters(in: .whitespacesAndNewlines) let current = username.trimmingCharacters(in: .whitespacesAndNewlines) guard !confirmed.isEmpty else { errorMessage = "Enter your username to confirm." return } guard confirmed == current else { errorMessage = "Username does not match." return } guard isLoggedIn else { errorMessage = "Log in first." return } isBusy = true Task { defer { isBusy = false } do { try await GolfAPI.shared.deleteAccount(username: current) stopPolling() await clearWebsiteCookies() await refreshAuth() snapshot = nil gameID = nil gamePassword = nil statusMessage = "Account deleted" } catch { errorMessage = error.localizedDescription } } } private func clearWebsiteCookies() async { let store = WKWebsiteDataStore.default() let types: Set = [WKWebsiteDataTypeCookies, WKWebsiteDataTypeLocalStorage] await withCheckedContinuation { (continuation: CheckedContinuation) in store.fetchDataRecords(ofTypes: types) { records in let relevant = records.filter { $0.displayName.contains("lschaefer.xyz") } store.removeData(ofTypes: types, for: relevant) { continuation.resume() } } } } func tapNewGame() { stopPolling() gameID = nil gamePassword = nil snapshot = nil onRequestExpand?() } func tapOpenGame() { onRequestExpand?() } func tapExit() { onRequestCollapse?() } func shareGame(newSession: Bool = false) { guard shareMode == .messagesBubble, let gameID else { return } let caption: String if snapshot == nil { caption = "Six Card Golf #\(gameID) — join to play" } else if isRoundOver { caption = "Six Card Golf #\(gameID) — round over" } else if isMyTurn { caption = "Six Card Golf #\(gameID) — open to play" } else { caption = "Six Card Golf #\(gameID) — your turn" } onShareGame?(gameID, gamePassword, caption, newSession) } func startGame() { errorMessage = nil guard isLoggedIn else { errorMessage = "Log in first." return } let points = max(1, draftPointsToEnd) let players = max(1, draftPlayersToStart) let bots = max(0, draftBots) let decks = max(1, draftDecks) isBusy = true Task { defer { isBusy = false } do { let password = MessageGameID.randomPassword() let id = try await GolfAPI.shared.createGame( name: Self.roomName(for: Date()), pointsToEnd: points, playersToStart: players, bots: bots, decks: decks, password: password ) gameID = id gamePassword = password snapshot = nil shareGame(newSession: true) statusMessage = "Game #\(id) created — waiting for \(players) players" await openGame(id: id) } catch { errorMessage = error.localizedDescription } } } func selectSlot(_ slot: Int) { guard isMyTurn, !isBusy else { return } selectedSlot = slot tryCommitMove() } func selectSource(_ source: SwapSource) { guard isMyTurn, !isBusy else { return } selectedSource = source tryCommitMove() } private func tryCommitMove() { guard let slot = selectedSlot, let source = selectedSource, let gameID, isMyTurn else { return } selectedSlot = nil selectedSource = nil isBusy = true snapshotGeneration += 1 let generation = snapshotGeneration Task { defer { isBusy = false } do { try await GolfAPI.shared.swap(gameID: gameID, slot: slot, source: source) try await refreshSnapshot(gameID: gameID, generation: generation) // Notify the chat when the turn leaves this player (Game Pigeon–style). if !isMyTurn { shareGame() } } catch { errorMessage = error.localizedDescription } } } func continueRound() { guard let gameID else { return } isBusy = true snapshotGeneration += 1 let generation = snapshotGeneration Task { defer { isBusy = false } do { try await refreshSnapshot( gameID: gameID, generation: generation, leaveRoundOver: true ) } catch { errorMessage = error.localizedDescription } } } private func refreshSnapshot( gameID: Int, generation: Int? = nil, leaveRoundOver: Bool = false ) async throws { let generation = generation ?? snapshotGeneration try await GolfAPI.shared.forceUpdate(gameID: gameID) if let snap = try await GolfAPI.shared.update(gameID: gameID) { applySnapshot(snap, generation: generation, leaveRoundOver: leaveRoundOver) return } try await GolfAPI.shared.forceUpdate(gameID: gameID) if let snap = try await GolfAPI.shared.update(gameID: gameID) { applySnapshot(snap, generation: generation, leaveRoundOver: leaveRoundOver) } } private func applySnapshot( _ snap: GolfAPISnapshot, generation: Int, leaveRoundOver: Bool = false ) { guard generation == snapshotGeneration else { return } // Hold the revealed end-of-round board until Continue (server may already have dealt). if !leaveRoundOver, snapshot?.action == "roundOver", snap.action != "roundOver" { return } snapshot = snap statusMessage = nil } private func openGame(id: Int) async { await refreshAuth() guard isLoggedIn else { statusMessage = "Log in to join game #\(id)" return } try? await GolfAPI.shared.join(gameID: id, password: gamePassword) do { snapshotGeneration += 1 try await refreshSnapshot(gameID: id, generation: snapshotGeneration) statusMessage = snapshot == nil ? "Waiting for players…" : nil startPolling(gameID: id) } catch { errorMessage = error.localizedDescription } } private func startPolling(gameID: Int) { stopPolling() pollTask = Task { [weak self] in while !Task.isCancelled { try? await Task.sleep(nanoseconds: 500_000_000) guard let self, self.gameID == gameID else { break } let (shouldPoll, generation) = await MainActor.run { let hold = self.isBusy || self.snapshot?.action == "roundOver" return (!hold, self.snapshotGeneration) } guard shouldPoll else { continue } if let snap = try? await GolfAPI.shared.update(gameID: gameID) { await MainActor.run { self.applySnapshot(snap, generation: generation) } } } } } func stopPolling() { pollTask?.cancel() pollTask = nil } /// e.g. "Golf Jan 1 2026" private static func roomName(for date: Date) -> String { let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") formatter.dateFormat = "MMM d yyyy" return "Golf \(formatter.string(from: date))" } deinit { pollTask?.cancel() } }