390 lines
13 KiB
Swift
390 lines
13 KiB
Swift
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<Void, Never>?
|
||
/// 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<String> = [WKWebsiteDataTypeCookies, WKWebsiteDataTypeLocalStorage]
|
||
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) 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()
|
||
}
|
||
}
|