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

MyApp/GameSessionStore.swift

44 KB · 921 lines · swift · Download from the repository page

MyApp/GameSessionStore.swift
import SwiftUIimport AVFoundationimport Observation @MainActor @Observablefinal class GameSessionStore {    var players = [Player(name: "Alex"), Player(name: "Jordan")]    var challengesPerTurn = 5    var responseSeconds = 3    private var updatingSelectionInternally = false    var fingers = 3 {        didSet {            let clamped = min(5, max(1, fingers))            if fingers != clamped {                fingers = clamped                return            }            draftChanged()        }    }    var hand: HandSelection = .either {        didSet {            draftChanged()        }    }    var selectedCategory: ActionCategory? = nil    var actionSource: ActionSelectionSource = .catalog    var enabledCategories: Set<ActionCategory> = Set(ActionCategory.allCases) {        didSet {            if enabledCategories != oldValue {                reconcileFilteredSelection()                draftChanged()            }        }    }    var seatedOnly = false {        didSet {            if seatedOnly != oldValue {                reconcileFilteredSelection()                draftChanged()            }        }    }    var recordingMode = true    var consent = false    var recordingAcknowledgement: RecordingAssent?    var participantAssents: [UUID: RecordingAssent] = [:]    var deleteAtEnd = false    var phase: ChallengePhase = .setup    var prompts: [Prompt] = []    var selectedPromptID = "hands-up" {        didSet {            guard !updatingSelectionInternally else { return }            if !selectedPromptID.isEmpty {                if let matched = prompts.first(where: { $0.matchesID(selectedPromptID) }) {                    if selectedPromptID != matched.id {                        updatingSelectionInternally = true                        selectedPromptID = matched.id                        updatingSelectionInternally = false                    }                    if selectedCategory != nil && selectedCategory != matched.actionCategory {                        selectedCategory = matched.actionCategory                    }                }                actionSource = .catalog            }            draftChanged()        }    }    var customText = "" {        didSet {            guard !updatingSelectionInternally else { return }            let trimmed = customText.trimmingCharacters(in: .whitespacesAndNewlines)            actionSource = trimmed.isEmpty ? .catalog : .custom            draftChanged()        }    }    var attempts: [Attempt] = []    var current: Attempt?    var sessionID = UUID()    var playerIndex = 0    var scoredThisTurn = 0    var hostID: UUID?    var hostTab: HostTab = .game    var speed: SpeechSpeed = .normal { didSet { draftChanged(); persistPreferences() } }    var placement: CuePlacement = .beginning { didSet { draftChanged(); persistPreferences() } }    var cueMode: CueMode = .spoken { didSet { draftChanged(); persistPreferences() } }    var cueDuration: CueDuration = .standard { didSet { draftChanged(); persistPreferences() } }    private var displayedCue: VisualCue?    private var cueOnset: ContinuousClock.Instant?    private var cueAcknowledged = false    var visualAvailable: Bool { recordingMode && cameraReady && accessoryEnabled && accessoryAvailable && accessoryPresented }    var isTestingVoice = false    private var voiceTestTask: Task<Void, Never>?    private var voiceTestGeneration = UUID()    private var turnProgress: [UUID: Int] = [:]    private var completedTurns: Set<UUID> = []    private var preferencesEnabled = false    private var restoring = true    private var persistenceHealthy = true    private let randomIndex: () -> Int    private var mediaLeases: [UUID: Int] = [:]    var sessionStarted = false    var frame: UIImage?    var cameraID = ""    var cameraName = "Not selected"    var cameras: [AVCaptureDevice] = []    var cameraReady = false    var isPreparingCamera = false    private var cameraGeneration = UUID()    private var lastOrientation = UIDeviceOrientation.unknown    private var rotationCoordinator: AVCaptureDevice.RotationCoordinator?    var accessoryEnabled = false    var accessoryAvailable = false    var accessoryPresented = false    var countdown: Int?    var message = ""    var audioRoute = "Not configured"    var isExporting = false    var simulatedParticipant = false    private let capture = CameraCaptureService()    private let speech = SpeechPromptService()    private var repository: LocalRepository?    private var challengeTask: Task<Void, Never>?    private var safetyTask: Task<Void, Never>?    private var observers: [Task<Void, Never>] = []    private var generation = UUID()    private var startInstant: ContinuousClock.Instant?    private let clock = ContinuousClock()     init(persistenceEnabled: Bool = true, storageDirectory: URL? = nil, randomIndex: @escaping () -> Int = { Int.random(in: 0..<3) }) {        self.randomIndex = randomIndex        preferencesEnabled = persistenceEnabled && storageDirectory == nil        do {            guard let url = Bundle.main.url(forResource: "Prompts", withExtension: "json") else {                throw PartyError.message("Bundled prompt catalog missing.")            }            prompts = try JSONDecoder().decode([Prompt].self, from: Data(contentsOf: url))            try Prompt.validateCatalog(prompts)            if persistenceEnabled {                var directory = storageDirectory                #if DEBUG                if ProcessInfo.processInfo.arguments.contains("--ui-testing") {                    preferencesEnabled = false                    directory = FileManager.default.temporaryDirectory.appendingPathComponent("PartyUITest-" + UUID().uuidString)                }                #endif                let repository = try LocalRepository(directory: directory)                self.repository = repository                attempts = try repository.load()                try repository.cleanup(&attempts)                if let saved = try repository.loadGame() { restore(saved) }                else if preferencesEnabled {                    cueMode = CueMode(rawValue: UserDefaults.standard.string(forKey: "party.cueMode") ?? "spoken") ?? .spoken                    cueDuration = CueDuration(rawValue: UserDefaults.standard.string(forKey: "party.cueDuration") ?? "standard") ?? .standard                    speed = SpeechSpeed(rawValue: UserDefaults.standard.object(forKey: "party.speed") as? Int ?? 1) ?? .normal                    placement = CuePlacement(rawValue: UserDefaults.standard.string(forKey: "party.placement") ?? "beginning") ?? .beginning                }            }        } catch { persistenceHealthy = false; message = "Saved data could not be read. Existing files were preserved. " + error.localizedDescription }        restoring = false        cameras = AVCaptureDevice.DiscoverySession(deviceTypes: [.builtInWideAngleCamera], mediaType: .video, position: .unspecified).devices        let capture = capture        Task { [weak self] in await capture.setFrameHandler { [weak self] image in            guard let self, self.cameraReady else { return }            self.frame = image        } }        UIDevice.current.beginGeneratingDeviceOrientationNotifications()        observers.append(Task { [weak self] in            for await _ in NotificationCenter.default.notifications(named: UIDevice.orientationDidChangeNotification) {                guard let self else { return }                let orientation = UIDevice.current.orientation                guard orientation.isPortrait || orientation.isLandscape else { continue }                let changed = self.lastOrientation != .unknown && self.lastOrientation != orientation                self.lastOrientation = orientation                if changed && (self.cameraReady || self.isPreparingCamera) {                    self.interrupt("Device rotated. Reframe before the next attempt; no penalty.")                }            }        })        observers.append(Task { [weak self] in            for await _ in NotificationCenter.default.notifications(named: ProcessInfo.thermalStateDidChangeNotification) {                guard let self else { return }                if ProcessInfo.processInfo.thermalState == .critical && (self.cameraReady || self.isPreparingCamera) {                    self.interrupt("Device needs to cool down. Recording stopped without penalty.")                }            }        })        for name in [AVCaptureSession.wasInterruptedNotification, AVCaptureSession.runtimeErrorNotification,                     AVAudioSession.interruptionNotification, AVAudioSession.routeChangeNotification] {            observers.append(Task { [weak self] in                for await _ in NotificationCenter.default.notifications(named: name) {                    guard let self, self.phase.active || self.cameraReady else { continue }                    self.interrupt("Capture or audio route changed. Reframe and retry; no penalty.")                }            })        }    }     var player: Player { players[playerIndex] }    var host: Player { players.first { $0.id == hostID } ?? players[PartyTurnScheduler.hostIndex(playerIndex: playerIndex, count: players.count)] }     var availableCategories: [ActionCategory] {        ActionCategory.allCases.filter { enabledCategories.contains($0) }    }     var visiblePrompts: [Prompt] {        prompts.filter { prompt in            enabledCategories.contains(prompt.actionCategory) && (!seatedOnly || prompt.seated)        }    }     func prompts(in category: ActionCategory) -> [Prompt] {        guard enabledCategories.contains(category) else { return [] }        return prompts            .filter { $0.actionCategory == category && (!seatedOnly || $0.seated) }            .sorted { lhs, rhs in                if lhs.sortOrder != rhs.sortOrder { return lhs.sortOrder < rhs.sortOrder }                return lhs.id < rhs.id            }    }     func availableActionCount(for category: ActionCategory) -> Int {        prompts(in: category).count    }     func prompt(for id: String) -> Prompt? {        guard !id.isEmpty else { return nil }        return prompts.first { $0.matchesID(id) }    }     var selectedPrompt: Prompt? {        guard !selectedPromptID.isEmpty else { return nil }        return visiblePrompts.first { $0.matchesID(selectedPromptID) }    }     var canEditChallengeSelection: Bool {        !phase.active && phase != .awaitingReview    }     func selectGroup(_ category: ActionCategory?) {        guard canEditChallengeSelection else { return }        if let category, !enabledCategories.contains(category) { return }        selectedCategory = category        updatingSelectionInternally = true        selectedPromptID = ""        updatingSelectionInternally = false        actionSource = .catalog        draftChanged()    }     func selectPrompt(_ prompt: Prompt) {        selectPrompt(id: prompt.id)    }     func selectPrompt(id: String) {        guard canEditChallengeSelection else { return }        guard let matched = visiblePrompts.first(where: { $0.matchesID(id) }) else { return }        if selectedPrompt?.id != matched.id && matched.supportsFingerParameters {            fingers = matched.defaultFingers            hand = matched.defaultHand        }        selectedCategory = matched.actionCategory        updatingSelectionInternally = true        selectedPromptID = matched.id        updatingSelectionInternally = false        actionSource = .catalog        draftChanged()    }     func setFingerCount(_ count: Int) {        guard canEditChallengeSelection else { return }        fingers = min(5, max(1, count))    }     func setHandSelection(_ newHand: HandSelection) {        guard canEditChallengeSelection else { return }        hand = newHand    }     func activateCustomAction() {        guard canEditChallengeSelection else { return }        let trimmed = customText.trimmingCharacters(in: .whitespacesAndNewlines)        guard !trimmed.isEmpty else { return }        actionSource = .custom        draftChanged()    }     func resetActionFilters() {        guard canEditChallengeSelection else { return }        enabledCategories = Set(ActionCategory.allCases)        seatedOnly = false    }     private func reconcileFilteredSelection() {        if let selectedCategory, !enabledCategories.contains(selectedCategory) {            self.selectedCategory = nil            updatingSelectionInternally = true            selectedPromptID = ""            updatingSelectionInternally = false            return        }        if !selectedPromptID.isEmpty,           !visiblePrompts.contains(where: { $0.matchesID(selectedPromptID) }) {            updatingSelectionInternally = true            if selectedCategory != nil {                selectedPromptID = ""            } else {                selectedPromptID = visiblePrompts.first?.id ?? ""            }            updatingSelectionInternally = false        }    }     var customValidation: String? { PromptDelivery.validationMessage(for: customText) }    var activeValidationError: String? {        actionSource == .custom ? customValidation : nil    }     var actionText: String {        let custom = customText.trimmingCharacters(in: .whitespacesAndNewlines)        switch actionSource {        case .custom:            return custom        case .catalog:            return selectedPrompt?.sentence(fingers: fingers, hand: hand) ?? ""        }    }     var selectedActionDisplayWording: String {        let custom = customText.trimmingCharacters(in: .whitespacesAndNewlines)        switch actionSource {        case .custom:            guard !custom.isEmpty else { return "" }            return PromptDelivery.capitalized(PromptDelivery.cleanAction(custom)) + "."        case .catalog:            return selectedPrompt?.displaySentence(fingers: fingers, hand: hand) ?? ""        }    }     var canSend: Bool {        phase == .ready &&        (cueMode == .spoken || visualAvailable) &&        !isTestingVoice &&        !isPreparingCamera &&        persistenceHealthy &&        (!recordingMode || (consent && cameraReady)) &&        !actionText.trimmingCharacters(in: .punctuationCharacters.union(.whitespacesAndNewlines)).isEmpty &&        activeValidationError == nil    }     var participant: ParticipantPresentation {        ParticipantPresentation(status: hostTab != .game && !phase.active ? "Waiting for the host" : phase == .completed ? (current?.verdict == .correct ? "Correct!" : "Next time!") :            phase == .speaking ? "Listen…" : phase == .responding ? "Your turn" :            phase.active ? "Recording" : "Waiting for the host",            countdown: countdown, recording: recordingMode && phase.active, cue: displayedCue)    }     func score(_ player: Player) -> Int {        attempts.filter { $0.snapshot.sessionID == sessionID && $0.snapshot.player.id == player.id && !$0.voided && $0.verdict == .correct }.count    }     func beginSession() {        guard !phase.active, (2...12).contains(players.count),              players.allSatisfy({ !$0.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) else { return }        stopVoiceTest()        sessionID = UUID(); playerIndex = 0; scoredThisTurn = 0; current = nil        turnProgress = [:]; completedTurns = []; hostID = players.last?.id        sessionStarted = true; hostTab = .game; consent = false        selectedCategory = nil        actionSource = .catalog        updatingSelectionInternally = true        selectedPromptID = ""        updatingSelectionInternally = false        phase = .framing        saveSession()        if !recordingMode { Task { await prepareCamera() } }    }     var canSelectPlayer: Bool { !phase.active && phase != .awaitingReview && !isPreparingCamera }    var canChangeTab: Bool { !phase.active && !isPreparingCamera }    var scoreboard: [ScoreboardEntry] { GameQueries.scoreboard(players: players, attempts: attempts, sessionID: sessionID) }     func selectPlayer(_ id: UUID) {        guard canSelectPlayer, let index = players.firstIndex(where: { $0.id == id }), index != playerIndex else { return }        if phase == .completed {            accountCompletedAttempt()            if scoredThisTurn >= challengesPerTurn {                completedTurns.insert(player.id)                turnProgress[player.id] = 0            }        }        current = nil        // The host remains the phone holder; selecting a player never changes hostID.        if hostID == nil { hostID = host.id }        playerIndex = index; scoredThisTurn = turnProgress[id, default: 0]        consent = participantAssents[id] != nil        if phase == .finished || phase == .completed { phase = cameraReady || !recordingMode ? .ready : .framing }        saveSession()    }    func setRecordingConsent(_ agrees: Bool) {        consent = agrees        participantAssents[player.id] = agrees ? RecordingAssent(date: Date(), scopeID: recordingAcknowledgement?.scopeID ?? sessionID) : nil        saveSession()    }    func selectHost(_ id: UUID) {        guard canSelectPlayer, players.contains(where: { $0.id == id }) else { return }        hostID = id; saveSession()    }    func selectTab(_ tab: HostTab) {        guard canChangeTab else { return }        stopVoiceTest(); hostTab = tab    }    func clearCustomSuggestion() {        guard canEditChallengeSelection, !customText.isEmpty else { return }        updatingSelectionInternally = true        customText = ""        updatingSelectionInternally = false        actionSource = .catalog        draftChanged()    }    private func draftChanged() {        guard !restoring, !phase.active else { return }        stopVoiceTest()        speech.invalidatePreparedAudio()        saveSession()    }    private func persistPreferences() {        guard preferencesEnabled, !restoring else { return }        UserDefaults.standard.set(cueMode.rawValue, forKey: "party.cueMode")        UserDefaults.standard.set(cueDuration.rawValue, forKey: "party.cueDuration")        UserDefaults.standard.set(speed.rawValue, forKey: "party.speed")        UserDefaults.standard.set(placement.rawValue, forKey: "party.placement")    }    func makeSnapshot(simonSays: Bool) throws -> ChallengeSnapshot {        guard !actionText.trimmingCharacters(in: .punctuationCharacters.union(.whitespacesAndNewlines)).isEmpty,              activeValidationError == nil else { throw PartyError.message(activeValidationError ?? "Choose an action.") }        guard let voice = AVSpeechSynthesisVoice(language: "en-US") ?? AVSpeechSynthesisVoice.speechVoices().first else {            throw PartyError.message("Install a speech voice in Settings to continue.")        }        let resolved = placement.resolve(randomIndex: randomIndex)        let delivery = DeliverySnapshot(speed: speed, rate: speed.rate, voiceID: voice.identifier,            language: voice.language, configuredPlacement: placement, resolvedPlacement: resolved)        let isCustom = actionSource == .custom        let catalogPrompt = isCustom ? nil : selectedPrompt        let resolvedWording = isCustom            ? (PromptDelivery.capitalized(PromptDelivery.cleanAction(actionText)) + ".")            : (catalogPrompt?.displaySentence(fingers: fingers, hand: hand) ?? (PromptDelivery.capitalized(PromptDelivery.cleanAction(actionText)) + "."))        return ChallengeSnapshot(            id: UUID(),            sessionID: sessionID,            player: player,            host: host,            promptID: isCustom ? "custom" : (catalogPrompt?.id ?? selectedPromptID),            instruction: PromptDelivery.transcript(                action: actionText,                includesCue: cueMode == .spoken && simonSays,                placement: cueMode == .visual ? .beginning : resolved            ),            simonSays: simonSays,            fingers: fingers,            responseDuration: Double(responseSeconds),            created: Date(),            delivery: delivery,            visualCue: cueMode == .visual ? VisualCue(id: UUID(), letter: simonSays ? "S" : "Z", duration: cueDuration.seconds) : nil,            suggestedPromptID: isCustom ? "custom" : catalogPrompt?.canonicalSuggestedID,            categoryID: isCustom ? "custom" : catalogPrompt?.actionCategory.rawValue,            catalogVersion: isCustom ? Prompt.currentCatalogVersion : (catalogPrompt?.catalogVersion ?? Prompt.currentCatalogVersion),            actionTitle: isCustom ? "Custom action" : catalogPrompt?.title,            actionText: resolvedWording,            hand: (catalogPrompt?.supportsFingerParameters == true) ? hand : nil,            actionSource: actionSource,            judgingMode: isCustom ? .manual : (catalogPrompt?.judgingSupport ?? .manual)        )    }    func testVoice(rate overrideRate: Float? = nil) {        guard !phase.active, !isTestingVoice else { return }        let token = UUID(); voiceTestGeneration = token        isTestingVoice = true        let rate = overrideRate ?? speed.rate        voiceTestTask = Task { [weak self] in            guard let self else { return }            let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".caf")            defer {                try? FileManager.default.removeItem(at: url)                if self.voiceTestGeneration == token { self.isTestingVoice = false }            }            do {                let audio = AVAudioSession.sharedInstance()                if !self.cameraReady {                    try audio.setCategory(.playback)                    try await AudioSessionController.activate(audio)                }                try Task.checkCancellation()                self.speech.rate = rate                try await self.speech.prepare("This is the selected speaking speed.", to: url)                try Task.checkCancellation()                try self.speech.play()                let deadline = ContinuousClock.now.advanced(by: .seconds(15))                while self.speech.playbackFinished == nil {                    try await Task.sleep(for: .milliseconds(30))                    guard ContinuousClock.now < deadline else { throw PartyError.message("Voice test timed out. Try again.") }                }            } catch {                if !Task.isCancelled { self.message = error.localizedDescription }            }        }    }    func stopVoiceTest() {        voiceTestGeneration = UUID()        voiceTestTask?.cancel(); voiceTestTask = nil        if isTestingVoice { speech.stop() }        isTestingVoice = false    }    private func accountCompletedAttempt() {        guard let current, current.verdict != nil, !current.voided else { return }        let id = current.snapshot.player.id        turnProgress[id, default: 0] += 1        scoredThisTurn = turnProgress[player.id, default: 0]        self.current = nil    }    func saveSession() {        guard !restoring, persistenceHealthy, sessionStarted else { return }        do {            try repository?.saveGame(SavedGame(id: sessionID, players: players, selectedPlayerID: player.id,                hostID: host.id, challengesPerTurn: challengesPerTurn, responseSeconds: responseSeconds,                recordingMode: recordingMode, deleteAtEnd: deleteAtEnd, currentAttemptID: current?.id,                finished: phase == .finished, turnProgress: turnProgress, completedTurns: completedTurns,                speed: speed, placement: placement, selectedPromptID: selectedPromptID, customText: customText,                fingers: fingers, cueMode: cueMode, cueDuration: cueDuration,                recordingAcknowledgement: recordingAcknowledgement, participantAssents: participantAssents,                selectedCategory: selectedCategory, hand: hand, actionSource: actionSource,                enabledCategories: Array(enabledCategories).sorted { $0.rawValue < $1.rawValue },                seatedOnly: seatedOnly))        } catch { message = "Could not save game: " + error.localizedDescription }    }    private func restore(_ saved: SavedGame) {        recordingAcknowledgement = saved.recordingAcknowledgement        participantAssents = saved.participantAssents ?? [:]        cueMode = saved.cueMode ?? .spoken; cueDuration = saved.cueDuration ?? .standard        guard (2...12).contains(saved.players.count),              let index = saved.players.firstIndex(where: { $0.id == saved.selectedPlayerID }) else { return }        sessionID = saved.id; players = saved.players; playerIndex = index; hostID = saved.hostID        challengesPerTurn = saved.challengesPerTurn; responseSeconds = saved.responseSeconds        recordingMode = saved.recordingMode; deleteAtEnd = saved.deleteAtEnd        turnProgress = saved.turnProgress; completedTurns = saved.completedTurns        scoredThisTurn = turnProgress[player.id, default: 0]        speed = saved.speed; placement = saved.placement        if let savedCategories = saved.enabledCategories, !savedCategories.isEmpty {            enabledCategories = Set(savedCategories)        }        if let savedSeated = saved.seatedOnly {            seatedOnly = savedSeated        }        selectedCategory = saved.selectedCategory        hand = saved.hand ?? .either        updatingSelectionInternally = true        selectedPromptID = saved.selectedPromptID        customText = saved.customText        updatingSelectionInternally = false        if let savedSource = saved.actionSource {            actionSource = savedSource        } else {            actionSource = saved.customText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? .catalog : .custom        }        fingers = saved.fingers; sessionStarted = true        current = attempts.first { $0.id == saved.currentAttemptID && !$0.voided }        phase = saved.finished ? .finished : current != nil ? (current?.verdict == nil ? .awaitingReview : .completed) : .framing        // Capture consent/permissions are intentionally never restored as technical readiness.    }    func directionChanged(ids: [String], outer: Bool) {        if phase.active {            if !ids.contains(cameraID) { interrupt("Camera direction changed. Attempt voided; reframe before retrying.") }        } else if !outer && cameraID.isEmpty, let id = ids.first,                  let camera = cameras.first(where: { $0.uniqueID == id }) {            cameraID = id; cameraName = camera.localizedName        }    }    func prepareCamera() async {        guard !isPreparingCamera, phase == .framing || phase == .failure || phase == .interrupted else { return }        isPreparingCamera = true        defer { isPreparingCamera = false }        let token = UUID()        cameraGeneration = token        if !recordingMode {            do {                let audio = AVAudioSession.sharedInstance()                try audio.setCategory(.playback, mode: .default)                try await AudioSessionController.activate(audio)                guard cameraGeneration == token else { return }                phase = .ready            } catch { phase = .failure; message = error.localizedDescription }            return        }        guard consent, !cameraID.isEmpty else { message = "Confirm consent and choose the camera facing the player."; return }        phase = .framing        do {            guard await AVCaptureDevice.requestAccess(for: .video), await AVCaptureDevice.requestAccess(for: .audio) else {                throw PartyError.message("Camera and microphone access are required for recording. Enable them in Settings or choose nonrecording mode.")            }            let audio = AVAudioSession.sharedInstance()            try audio.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker])            try await AudioSessionController.activate(audio)            try audio.overrideOutputAudioPort(.speaker)            audioRoute = audio.currentRoute.outputs.map(\.portName).joined(separator: ", ")            guard cameraGeneration == token else { return }            guard let camera = cameras.first(where: { $0.uniqueID == cameraID }) else { throw PartyError.message("Selected camera is unavailable.") }            let rotation = AVCaptureDevice.RotationCoordinator(device: camera, previewLayer: nil)            rotationCoordinator = rotation            try await capture.configure(cameraID: cameraID, rotationAngle: rotation.videoRotationAngleForHorizonLevelCapture)            guard cameraGeneration == token else { await capture.stop(); return }            cameraName = cameras.first(where: { $0.uniqueID == cameraID })?.localizedName ?? "Selected camera"            cameraReady = true; accessoryEnabled = true; phase = .ready            message = "Check that the preview shows the player. Listen to the whole instruction."        } catch {            await capture.stop()            cameraReady = false; accessoryEnabled = false            phase = .failure; message = error.localizedDescription        }    }    func send(simonSays: Bool) {        guard canSend, let repository else { return }        let snapshot: ChallengeSnapshot        do { snapshot = try makeSnapshot(simonSays: simonSays) }        catch { message = error.localizedDescription; return }        speech.rate = snapshot.delivery?.rate ?? AVSpeechUtteranceDefaultSpeechRate        speech.voiceID = snapshot.delivery?.voiceID        displayedCue = nil; cueOnset = nil; cueAcknowledged = false        current = Attempt(snapshot: snapshot)        phase = .preparing; message = ""        let token = UUID(); generation = token        UIApplication.shared.isIdleTimerDisabled = true        safetyTask = Task { [weak self] in            try? await Task.sleep(for: .seconds(20))            guard !Task.isCancelled, let self, self.generation == token, self.phase.active else { return }            self.interrupt("Safety deadline reached. Retry without penalty.")        }        challengeTask = Task { [weak self] in            guard let self else { return }            do {                let narrationName = snapshot.id.uuidString + ".caf"                self.current?.narrationFile = narrationName                try await self.speech.prepare(snapshot.instruction, to: repository.url(narrationName))                try Task.checkCancellation()                guard self.generation == token else { return }                self.current?.narrationFile = narrationName                guard self.speech.duration + snapshot.responseDuration + 2.25 < 19 else {                    throw PartyError.message("This prompt is too long for a bounded recording. Shorten it or reduce the response window.")                }                if self.recordingMode {                    let values = try repository.directory.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey])                    guard (values.volumeAvailableCapacityForImportantUsage ?? 0) > 150_000_000 else { throw PartyError.message("Free at least 150 MB to record.") }                    let name = snapshot.id.uuidString + ".mov"                    self.current?.rawFile = name                    try await self.capture.start(url: repository.url(name))                    self.phase = .recordingLeadIn                    try await self.waitForMedia(0.75)                } else {                    self.startInstant = self.clock.now                }                try Task.checkCancellation()                self.phase = .speaking                self.current?.speechStart = self.recordingMode ? try await self.capture.timeline() : 0                try self.speech.play()                while self.speech.playbackFinished == nil {                    try await Task.sleep(for: .milliseconds(30))                    try Task.checkCancellation()                }                guard self.speech.playbackFinished == true else {                    throw PartyError.message(self.speech.playbackError ?? "Instruction was interrupted. No penalty.")                }                let speechEnd = self.recordingMode ? try await self.capture.timeline() : self.speech.duration                self.current?.speechEnd = speechEnd                if let cue = snapshot.visualCue {                    guard self.visualAvailable else { throw PartyError.message("Outer display unavailable. Visual attempt voided.") }                    self.displayedCue = cue                    let presentationDeadline = self.clock.now.advanced(by: .seconds(0.5))                    while !self.cueAcknowledged {                        try Task.checkCancellation()                        guard self.visualAvailable, self.clock.now < presentationDeadline else {                            throw PartyError.message("Visual cue was not presented. No penalty.")                        }                        try await Task.sleep(for: .milliseconds(10))                    }                }                self.phase = .responding                let deadline = (self.cueOnset ?? self.clock.now).advanced(by: .seconds(snapshot.responseDuration))                while self.clock.now < deadline {                    if let cue = self.displayedCue, let onset = self.cueOnset,                       self.clock.now >= onset.advanced(by: .seconds(cue.duration)) {                        self.displayedCue = nil                    }                    if snapshot.visualCue != nil && !self.visualAvailable {                        throw PartyError.message("Outer display disappeared. No penalty.")                    }                    let remaining = self.clock.now.duration(to: deadline)                    self.countdown = max(1, Int(ceil(Double(remaining.components.seconds) + Double(remaining.components.attoseconds) / 1e18)))                    try await Task.sleep(for: .milliseconds(100))                }                self.countdown = nil; self.phase = .evaluating                if self.recordingMode {                    self.phase = .recordingTail                    let end = try await self.capture.timeline()                    try await self.waitForMedia(end + 1.5)                    self.current?.duration = try await self.capture.timeline()                    _ = try await self.capture.finish()                }                try Task.checkCancellation()                self.phase = .awaitingReview                self.safetyTask?.cancel()                UIApplication.shared.isIdleTimerDisabled = false                self.persistCurrent()            } catch {                guard self.generation == token else { return }                self.interrupt(error.localizedDescription)            }        }    }    func cueAppeared(_ id: UUID) {        guard let cue = displayedCue, cue.id == id, !cueAcknowledged, phase == .speaking, visualAvailable else { return }        cueOnset = clock.now        let token = generation        Task {            do {                let time = try await capture.timeline()                guard generation == token, displayedCue?.id == id, visualAvailable else { return }                current?.cueTiming = CueTiming(start: time, duration: cue.duration)                cueAcknowledged = true            } catch { interrupt("Cue timing unavailable. No penalty.") }        }    }    func cueDisappeared(_ id: UUID) {        if displayedCue?.id == id && phase.active {            interrupt("Visual cue disappeared before delivery finished. No penalty.")        } else if current?.snapshot.visualCue?.id == id, let timing = current?.cueTiming, phase.active {            let token = generation            Task {                do {                    let end = try await capture.timeline()                    guard generation == token, current?.snapshot.visualCue?.id == id else { return }                    current?.cueTiming = CueTiming(start: timing.start, duration: max(0, end - timing.start))                } catch { interrupt("Cue timing unavailable. No penalty.") }            }        }    }    private func waitForMedia(_ target: Double) async throws {        let deadline = clock.now.advanced(by: .seconds(4))        while try await capture.timeline() < target {            try Task.checkCancellation()            guard clock.now < deadline else { throw PartyError.message("Camera or microphone samples stopped arriving.") }            try await Task.sleep(for: .milliseconds(20))        }    }    func judge(_ verdict: Verdict) {        guard let current, current.snapshot.visualCue == nil || current.cueTiming != nil,              phase == .awaitingReview || phase == .completed else { return }        self.current?.history.append(VerdictChange(verdict: verdict, date: Date()))        phase = .completed; persistCurrent()    }    func next() {        guard phase == .completed else { return }        let departing = player        accountCompletedAttempt()        if scoredThisTurn >= challengesPerTurn {            turnProgress[departing.id] = 0; completedTurns.insert(departing.id); scoredThisTurn = 0            cameraReady = false; accessoryEnabled = false; consent = false; frame = nil            phase = .framing            let token = UUID(); cameraGeneration = token            Task {                await capture.stop()                guard cameraGeneration == token else { return }                if !recordingMode && phase == .framing { await prepareCamera() }            }            if completedTurns.count == players.count {                phase = .finished                if deleteAtEnd { deleteSession() }            } else {                hostID = departing.id                playerIndex = (playerIndex + 1) % players.count                scoredThisTurn = turnProgress[player.id, default: 0]                message = "Pass the phone to \(host.name). \(player.name) is playing next."            }        } else { phase = cameraReady || !recordingMode ? .ready : .framing }        saveSession()    }    func voidAttempt() {        guard current.map({ !isMediaInUse($0.id) }) ?? true else { return }        interrupt("Attempt voided. No points or challenge consumed.", voidReviewedAttempt: true)    }    func interrupt(_ reason: String, voidReviewedAttempt: Bool = false) {        if reason.contains("Consent withdrawn") {            participantAssents[player.id] = nil            consent = false        }        displayedCue = nil; cueOnset = nil; cueAcknowledged = false        let previousPhase = phase        let invalidateAttempt = phase.active || voidReviewedAttempt        generation = UUID(); cameraGeneration = UUID()        challengeTask?.cancel(); safetyTask?.cancel(); speech.stop()        countdown = nil; accessoryEnabled = false; cameraReady = false        UIApplication.shared.isIdleTimerDisabled = false        if var attempt = current, invalidateAttempt {            attempt.voided = true            // Incomplete media are removed, rather than exposed as a successfully saved clip.            let abandoned = attempt            Task {                await capture.stop()                try? repository?.deleteFiles(abandoned)            }            attempt.rawFile = nil; attempt.narrationFile = nil; attempt.exportFile = nil            current = attempt; persistCurrent()        } else { Task { await capture.stop() } }        frame = nil        phase = !invalidateAttempt && (previousPhase == .awaitingReview || previousPhase == .completed) ? previousPhase : .interrupted        message = reason        saveSession()    }    func persistCurrent() {        guard let current else { return }        if let index = attempts.firstIndex(where: { $0.id == current.id }) { attempts[index] = current }        else { attempts.append(current) }        save(); saveSession()    }    private func save() {        guard persistenceHealthy else { return }        do { try repository?.save(attempts) } catch { message = "Could not save metadata: " + error.localizedDescription }    }    func acquireMedia(_ id: UUID) { mediaLeases[id, default: 0] += 1 }    func releaseMedia(_ id: UUID) {        if mediaLeases[id, default: 0] <= 1 { mediaLeases[id] = nil }        else { mediaLeases[id, default: 0] -= 1 }    }    func isMediaInUse(_ id: UUID) -> Bool { mediaLeases.keys.contains(id) }    func mediaURL(_ name: String) -> URL? { repository?.url(name) }    func delete(_ attempt: Attempt) {        guard !mediaLeases.keys.contains(attempt.id), !isExporting else { message = "Close the preview or share sheet before deleting this clip."; return }        do {            for name in [attempt.rawFile, attempt.exportFile].compactMap({ $0 }) {                if let url = mediaURL(name) { ClipThumbnailCache.shared.remove(url) }            }            try repository?.deleteFiles(attempt)            // Retain score events while removing the associated media.            if let index = attempts.firstIndex(where: { $0.id == attempt.id }) {                attempts[index].rawFile = nil; attempts[index].narrationFile = nil; attempts[index].exportFile = nil                if current?.id == attempt.id { current = attempts[index] }            }            save()        } catch { message = error.localizedDescription }    }    func deleteSession() {        for attempt in attempts where attempt.snapshot.sessionID == sessionID { delete(attempt) }    }    func deleteAll() {        guard !phase.active, mediaLeases.isEmpty, !isExporting else { return }        do {            for attempt in attempts { try repository?.deleteFiles(attempt) }            generation = UUID(); cameraGeneration = UUID()            challengeTask?.cancel(); safetyTask?.cancel(); speech.stop()            cameraReady = false; accessoryEnabled = false; consent = false; frame = nil            Task { await capture.stop() }            current = nil; countdown = nil            attempts.removeAll(); save()            try repository?.removeGame(); sessionStarted = false            players = [Player(name: "Alex"), Player(name: "Jordan")]            playerIndex = 0; scoredThisTurn = 0; sessionID = UUID(); phase = .setup        } catch { message = error.localizedDescription }    }    func visualPlaybackURL(_ attempt: Attempt) async throws -> URL {        guard let repository, let raw = attempt.rawFile, let cue = attempt.snapshot.visualCue,              let timing = attempt.cueTiming else { throw PartyError.message("Visual cue metadata is unavailable.") }        let name = attempt.id.uuidString + "-visual.mp4"        let url = repository.url(name)        if FileManager.default.fileExists(atPath: url.path),           (try? await ClipValidation.validate(url)) != nil { return url }        try? FileManager.default.removeItem(at: url)        do {            try await VisualCueExport.export(raw: repository.url(raw), cue: cue, timing: timing, to: url)            _ = try await ClipValidation.validate(url)            if let index = attempts.firstIndex(where: { $0.id == attempt.id }) { attempts[index].exportFile = name }            if current?.id == attempt.id { current?.exportFile = name }            save()            return url        } catch {            try? FileManager.default.removeItem(at: url)            throw error        }    }    func export(_ attempt: Attempt) async {        if attempt.snapshot.visualCue != nil {            guard !isExporting, !isMediaInUse(attempt.id) else { return }            isExporting = true            acquireMedia(attempt.id)            defer { isExporting = false; releaseMedia(attempt.id) }            do { _ = try await visualPlaybackURL(attempt) }            catch { message = "Export failed; retry from preview. " + error.localizedDescription }            return        }        guard !isExporting, !mediaLeases.keys.contains(attempt.id), let repository, let raw = attempt.rawFile, let voice = attempt.narrationFile, let offset = attempt.speechStart else { return }        isExporting = true        defer { isExporting = false }        let name = attempt.id.uuidString + "-narrated.mp4"        do {            let url = repository.url(name)            if FileManager.default.fileExists(atPath: url.path),               (try? await ClipValidation.validate(url)) == nil {                try FileManager.default.removeItem(at: url)            }            if !FileManager.default.fileExists(atPath: url.path) {                try await ClipExportService.export(raw: repository.url(raw), narration: repository.url(voice), offset: offset, to: url)            }            _ = try await ClipValidation.validate(url)            if let index = attempts.firstIndex(where: { $0.id == attempt.id }) { attempts[index].exportFile = name }            if current?.id == attempt.id { current?.exportFile = name }            save()        } catch {            try? FileManager.default.removeItem(at: repository.url(name))            message = "Export failed; original preserved. " + error.localizedDescription        }    }}