Skip to content
RPRichard PruittTech & Cyber
Navigation
repo://simon-says-duo/MyApp/GameFlowStore.swift

MyApp/GameFlowStore.swift

9.6 KB · 214 lines · swift · Download from the repository page

MyApp/GameFlowStore.swift
import SwiftUIimport AVFoundation enum AppFlowStage: String { case consent, setup, game }struct RecordingAssent: Codable, Equatable {    var version = 1    let date: Date    let scopeID: UUID}struct SetupDraft: Codable {    var id = UUID()    var players = [Player(name: "Alex"), Player(name: "Jordan")]    var activePlayerID: UUID?    var hostID: UUID?    var acknowledgement: RecordingAssent?    var assents: [UUID: RecordingAssent] = [:]    var recording = true    var challenges = 5    var response = 3    var speed: SpeechSpeed = .normal    var placement: CuePlacement = .beginning    var cueMode: CueMode = .spoken    var cueDuration: CueDuration = .standard    var seated = false    var deleteAtEnd = false    var enabledCategories: [ActionCategory]? = nil     var categorySet: Set<ActionCategory> {        get {            guard let enabledCategories, !enabledCategories.isEmpty else {                return Set(ActionCategory.allCases)            }            return Set(enabledCategories)        }        set {            enabledCategories = Array(newValue).sorted { $0.rawValue < $1.rawValue }        }    }}@MainActor @Observablefinal class GameFlowStore {    let game: GameSessionStore    var stage: AppFlowStage = .consent    var draft: SetupDraft { didSet { persistDraft() } }    var acknowledged = false    var permissionsGranted = false    var busy = false    var error = ""    var permissionDenied = false    var libraryPresented = false    private var resuming = false    private let requestPermissions: () async -> Bool    private let draftURL: URL?     init(game: GameSessionStore, persist: Bool = true, requestPermissions: @escaping () async -> Bool = GameFlowStore.authorize) {        self.game = game        self.requestPermissions = requestPermissions        draftURL = persist ? game.mediaURL("setup-draft.json") : nil        if let draftURL, let data = try? Data(contentsOf: draftURL), let saved = try? JSONDecoder().decode(SetupDraft.self, from: data) {            draft = saved        } else {            draft = SetupDraft()            draft.players = game.players            draft.speed = game.speed; draft.placement = game.placement            draft.cueMode = game.cueMode; draft.cueDuration = game.cueDuration            draft.challenges = game.challengesPerTurn; draft.response = game.responseSeconds            draft.deleteAtEnd = game.deleteAtEnd            draft.categorySet = game.enabledCategories        }        // Cold launch always requires a fresh acknowledgement, never capture or speech.    }    static func authorize() async -> Bool {        for media in [AVMediaType.video, .audio] {            switch AVCaptureDevice.authorizationStatus(for: media) {            case .authorized: continue            case .notDetermined:                guard await AVCaptureDevice.requestAccess(for: media) else { return false }            default: return false            }        }        return true    }    var canResume: Bool { game.sessionStarted && game.phase != .finished }    func continueConsent(recording: Bool) async {        guard !busy else { return }        guard !recording || acknowledged else { error = "Acknowledge recording consent, or choose nonrecording play."; return }        busy = true        defer { busy = false }        error = ""; permissionDenied = false        if recording {            permissionsGranted = await requestPermissions()            guard permissionsGranted else {                permissionDenied = true                error = "Camera and microphone access are unavailable. Open Settings or choose nonrecording play."                return            }            draft.acknowledgement = RecordingAssent(date: Date(), scopeID: draft.id)        }        draft.recording = recording        if !recording { draft.cueMode = .spoken }        stage = .setup    }    func back() {        guard stage == .setup, !busy else { return }        stage = .consent    }    func addPlayer() {        guard draft.players.count < 12 else { return }        draft.players.append(Player(name: "Player \(draft.players.count + 1)"))    }    func removePlayer(_ id: UUID) {        guard draft.players.count > 2 else { return }        draft.players.removeAll { $0.id == id }; draft.assents[id] = nil        if draft.activePlayerID == id { draft.activePlayerID = nil }        if draft.hostID == id { draft.hostID = nil }    }    func assent(_ id: UUID, agrees: Bool) {        draft.assents[id] = agrees ? RecordingAssent(date: Date(), scopeID: draft.id) : nil    }    func toggleCategory(_ category: ActionCategory, enabled: Bool) {        var current = draft.categorySet        if enabled {            current.insert(category)        } else if current.count > 1 {            current.remove(category)        }        draft.categorySet = current    }    var validation: String? {        if !(2...12).contains(draft.players.count) { return "Add between 2 and 12 players." }        if draft.players.contains(where: { $0.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || $0.name.count > 40 }) {            return "Give every player a name of 1–40 characters."        }        if !(1...10).contains(draft.challenges) || !(3...10).contains(draft.response) { return "Check turn and response limits." }        if !game.prompts.contains(where: { draft.categorySet.contains($0.actionCategory) && (!draft.seated || $0.seated) }) {            return "Enable a group with available actions or turn off Seated-friendly mode."        }        if draft.recording {            if draft.acknowledgement == nil || !permissionsGranted { return "Complete recording consent and permissions first." }            if draft.players.contains(where: { draft.assents[$0.id]?.scopeID != draft.id }) { return "Confirm each participant’s agreement before recording them." }        } else if draft.cueMode == .visual { return "Visual S/Z requires recording and the outer camera display. Choose spoken mode." }        return nil    }    func startGame() {        guard stage == .setup, !busy else { return }        guard validation == nil else { error = validation ?? ""; return }        busy = true        defer { busy = false }        game.stopVoiceTest()        if !resuming {            game.players = draft.players            game.challengesPerTurn = draft.challenges; game.responseSeconds = draft.response            game.recordingMode = draft.recording; game.deleteAtEnd = draft.deleteAtEnd            game.speed = draft.speed; game.placement = draft.placement            game.cueMode = draft.cueMode; game.cueDuration = draft.cueDuration            game.enabledCategories = draft.categorySet            game.seatedOnly = draft.seated            game.beginSession()            game.playerIndex = game.players.firstIndex { $0.id == draft.activePlayerID } ?? 0            game.hostID = draft.hostID ?? game.players.last?.id        }        if resuming { game.recordingMode = draft.recording; game.cueMode = draft.cueMode }        game.recordingAcknowledgement = draft.acknowledgement        game.participantAssents = draft.assents        game.consent = !game.recordingMode || draft.assents[game.player.id] != nil        stage = .game; error = ""; resuming = false        game.saveSession()        // Preview can start only after this explicit stage transition. Recording still requires Send.        if game.phase == .framing && (!game.recordingMode || !game.cameraID.isEmpty) {            Task { await game.prepareCamera() }        }    }    func resume() {        guard canResume, !busy else { return }        resuming = true        draft = SetupDraft()        draft.players = game.players; draft.activePlayerID = game.player.id; draft.hostID = game.host.id        draft.recording = game.recordingMode        draft.challenges = game.challengesPerTurn; draft.response = game.responseSeconds        draft.speed = game.speed; draft.placement = game.placement        draft.cueMode = game.cueMode; draft.cueDuration = game.cueDuration        draft.seated = game.seatedOnly        draft.categorySet = game.enabledCategories        draft.deleteAtEnd = game.deleteAtEnd        acknowledged = false        error = "Confirm this group’s consent to resume. Scores and the pending result stay saved."    }    var isResuming: Bool { resuming }    func newGame() {        guard game.canChangeTab else { return }        game.stopVoiceTest()        if game.cameraReady || game.isPreparingCamera { game.interrupt("Camera stopped for setup. No recording in setup.") }        if stage == .game {            draft.players = game.players; draft.speed = game.speed; draft.placement = game.placement            draft.cueMode = game.cueMode; draft.cueDuration = game.cueDuration            draft.challenges = game.challengesPerTurn; draft.response = game.responseSeconds            draft.seated = game.seatedOnly; draft.deleteAtEnd = game.deleteAtEnd            draft.categorySet = game.enabledCategories        }        resuming = false; draft.id = UUID(); draft.acknowledgement = nil; draft.assents = [:]        acknowledged = false; permissionsGranted = false        stage = .consent; error = ""    }    func testVoice() {        // Existing speech service, without changing the active game's persisted delivery defaults.        game.testVoice(rate: draft.speed.rate)    }    private func persistDraft() {        guard let draftURL else { return }        do { try JSONEncoder().encode(draft).write(to: draftURL, options: [.atomic, .completeFileProtection]) }        catch { self.error = "Could not save setup draft: " + error.localizedDescription }    }}