MyApp/PromptDelivery.swift
8.6 KB · 280 lines · swift · Download from the repository page
import Foundationimport AVFoundation enum CuePlacement: String, Codable, CaseIterable, Identifiable, Sendable { case beginning case middle case end case random var id: String { rawValue } var title: String { switch self { case .beginning: "Beginning" case .middle: "Middle" case .end: "End" case .random: "Random per challenge" } } static let concrete: [CuePlacement] = [.beginning, .middle, .end] func resolve(randomIndex: () -> Int) -> CuePlacement { PromptDelivery.resolvePlacement(self, randomIndex: randomIndex) }} enum SpeechSpeed: Int, Codable, CaseIterable, Identifiable, Sendable { case slow = 0 case normal = 1 case fast = 2 case veryFast = 3 var id: Int { rawValue } var title: String { switch self { case .slow: "Slow" case .normal: "Normal" case .fast: "Fast" case .veryFast: "Very fast" } } var rate: Float { let base = AVSpeechUtteranceDefaultSpeechRate let raw: Float = switch self { case .slow: base - 0.08 case .normal: base case .fast: base + 0.06 case .veryFast: base + 0.11 } return min(AVSpeechUtteranceMaximumSpeechRate, max(AVSpeechUtteranceMinimumSpeechRate, raw)) }} struct DeliverySnapshot: Codable, Equatable, Sendable { let speed: SpeechSpeed let rate: Float let voiceID: String let language: String let configuredPlacement: CuePlacement let resolvedPlacement: CuePlacement} enum PromptDelivery { static let leadIn = "For this challenge" static func cleanAction(_ raw: String) -> String { let collapsed = raw .split(whereSeparator: \.isWhitespace) .joined(separator: " ") .trimmingCharacters(in: CharacterSet(charactersIn: ".!?")) .trimmingCharacters(in: .whitespacesAndNewlines) guard let first = collapsed.first else { return "" } return first.lowercased() + collapsed.dropFirst() } static func capitalized(_ text: String) -> String { guard let first = text.first else { return "" } return first.uppercased() + text.dropFirst() } static func validationMessage(for customText: String) -> String? { let trimmed = customText.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } guard trimmed.unicodeScalars.contains(where: CharacterSet.alphanumerics.contains) else { return "Enter an action with letters or numbers." } let pattern = #"(?i)\bsimon\b[\s\p{P}]*\bsays\b"# if trimmed.range(of: pattern, options: .regularExpression) != nil { return "Enter only the action. Use SIMON SAYS or SEND to control the cue." } return nil } static func resolvePlacement(_ configured: CuePlacement, randomIndex: () -> Int) -> CuePlacement { guard configured == .random else { return configured } let choices = CuePlacement.concrete let index = abs(randomIndex()) % choices.count return choices[index] } static func transcript(action rawAction: String, includesCue: Bool, placement: CuePlacement) -> String { let action = cleanAction(rawAction) guard !action.isEmpty else { return "" } let resolved = placement == .random ? .beginning : placement switch (includesCue, resolved) { case (true, .beginning): return "Simon says, \(action)." case (false, .beginning): return "\(capitalized(action))." case (true, .middle): return "\(leadIn), Simon says, \(action)." case (false, .middle): return "\(leadIn), \(action)." case (true, .end): return "\(capitalized(action)). Simon says." case (false, .end): return "\(capitalized(action))." case (_, .random): return "\(capitalized(action))." } }} enum HostTab: String, CaseIterable, Identifiable { case game = "Game" case scoreboard = "Scoreboard" case clips = "Clips" var id: String { rawValue } var symbol: String { switch self { case .game: "gamecontroller" case .scoreboard: "list.number" case .clips: "film.stack" } }} enum ClipScope: String, CaseIterable, Identifiable { case currentGame = "Current Game" case allSaved = "All Saved" var id: String { rawValue }} struct ScoreboardEntry: Identifiable, Equatable { var id: UUID { player.id } let rank: Int let player: Player let points: Int let correct: Int let wrong: Int let completed: Int var successRate: String { guard completed > 0 else { return "--" } let percent = Int((Double(correct) / Double(completed) * 100).rounded()) return "\(percent)%" }} enum GameQueries { static func scoreboard(players: [Player], attempts: [Attempt], sessionID: UUID) -> [ScoreboardEntry] { let raw = players.enumerated().map { offset, player in let judged = attempts.filter { $0.snapshot.sessionID == sessionID && $0.snapshot.player.id == player.id && !$0.voided && $0.verdict != nil } let correct = judged.filter { $0.verdict == .correct }.count let wrong = judged.filter { $0.verdict == .wrong }.count return (offset: offset, player: player, points: correct, correct: correct, wrong: wrong, completed: judged.count) }.sorted { if $0.points != $1.points { return $0.points > $1.points } if $0.correct != $1.correct { return $0.correct > $1.correct } return $0.offset < $1.offset } var rank = 1 return raw.enumerated().map { index, item in if index > 0 && (item.points != raw[index - 1].points || item.correct != raw[index - 1].correct) { rank = index + 1 } return ScoreboardEntry(rank: rank, player: item.player, points: item.points, correct: item.correct, wrong: item.wrong, completed: item.completed) } } static func clips(attempts: [Attempt], sessionID: UUID, scope: ClipScope, playerID: UUID?) -> [Attempt] { attempts.filter { attempt in guard attempt.rawFile != nil else { return false } if scope == .currentGame && attempt.snapshot.sessionID != sessionID { return false } if let playerID, attempt.snapshot.player.id != playerID { return false } return true } }} struct SavedGame: Codable, Equatable { var id: UUID var players: [Player] var selectedPlayerID: UUID var hostID: UUID? var challengesPerTurn: Int var responseSeconds: Int var recordingMode: Bool var deleteAtEnd: Bool var currentAttemptID: UUID? var finished: Bool var turnProgress: [UUID: Int] var completedTurns: Set<UUID> var speed: SpeechSpeed var placement: CuePlacement var selectedPromptID: String var customText: String var fingers: Int var cueMode: CueMode? var cueDuration: CueDuration? var recordingAcknowledgement: RecordingAssent? var participantAssents: [UUID: RecordingAssent]? var selectedCategory: ActionCategory? = nil var hand: HandSelection? = nil var actionSource: ActionSelectionSource? = nil var enabledCategories: [ActionCategory]? = nil var seatedOnly: Bool? = nil} enum CueMode: String, Codable, CaseIterable, Identifiable, Sendable { case spoken case visual var id: String { rawValue } var title: String { switch self { case .spoken: "Spoken Simon Says" case .visual: "Visual S/Z" } }} enum CueDuration: String, Codable, CaseIterable, Identifiable, Sendable { case brief case standard case extended var id: String { rawValue } var title: String { switch self { case .brief: "Brief (0.5s)" case .standard: "Standard (0.75s)" case .extended: "Extended (1.5s)" } } var seconds: Double { switch self { case .brief: 0.5 case .standard: 0.75 case .extended: 1.5 } }} struct VisualCue: Codable, Equatable, Sendable { let id: UUID let letter: String let duration: Double var requiresAction: Bool { letter == "S" }} struct CueTiming: Codable, Equatable, Sendable { let start: Double let duration: Double func visible(at time: Double) -> Bool { start.isFinite && duration > 0 && time >= start && time < start + duration }}