six-card-golf/Shared/UI/GameController.swift
Lukas Schaefer e609ea881f
Allow deleting account
Signed-off-by: Lukas Schaefer <lukas@lschaefer.xyz>
2026-09-01 20:35:00 -04:00

390 lines
13 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 Pigeonstyle).
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()
}
}