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

MyApp/GameModels.swift

20 KB · 591 lines · swift · Download from the repository page

MyApp/GameModels.swift
import Foundation enum PartyError: LocalizedError {    case message(String)    var errorDescription: String? {        switch self { case .message(let text): text }    }} struct Player: Identifiable, Codable, Equatable, Hashable, Sendable {    var id = UUID()    var name: String    var avatar = "person.crop.circle.fill"} enum ActionCategory: String, Codable, CaseIterable, Identifiable, Sendable {    case poses    case hands    case balances    case movements    case expressions     var id: String { rawValue }     var title: String {        switch self {        case .poses: "Poses"        case .hands: "Hands"        case .balances: "Balances"        case .movements: "Movements"        case .expressions: "Expressions"        }    }     var summary: String {        switch self {        case .poses: "Hold a clear upper-body or full-body pose."        case .hands: "Show a hand shape, finger count, or gesture."        case .balances: "Simple standing and balance challenges."        case .movements: "Short movements, claps, waves, or two-step actions."        case .expressions: "Friendly facial expressions and head movements."        }    }     var symbol: String {        switch self {        case .poses: "figure.arms.open"        case .hands: "hand.raised.fill"        case .balances: "figure.stand"        case .movements: "figure.dance"        case .expressions: "face.smiling"        }    }     var comfortNote: String? {        switch self {        case .balances:            "Use a clear, level space. Choose a comfortable action. Skip anytime."        default:            nil        }    }     static func resolve(_ raw: String) -> ActionCategory {        switch raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {        case "poses", "pose": .poses        case "hands", "hand": .hands        case "balances", "balance": .balances        case "movements", "movement": .movements        case "expressions", "expression": .expressions        default: .poses        }    }} enum ActionKind: String, Codable, CaseIterable, Identifiable, Sendable {    case staticPose    case handShape    case balance    case repeatedMovement    case movementSequence    case facialAction     var id: String { rawValue }} enum FramingRequirement: String, Codable, CaseIterable, Identifiable, Sendable {    case upperBody    case oneHand    case bothHands    case fullBodyFeet    case face     var id: String { rawValue }     var shortHint: String {        switch self {        case .upperBody: "Show upper body"        case .oneHand, .bothHands: "Show hands"        case .fullBodyFeet: "Show feet"        case .face: "Show face"        }    }     var guidance: String {        switch self {        case .upperBody: "Upper body and head clearly visible"        case .oneHand: "One hand clearly visible"        case .bothHands: "Both hands clearly visible"        case .fullBodyFeet: "Full body including feet"        case .face: "Face clearly visible"        }    }     static func infer(from framing: String, category: ActionCategory, seated: Bool) -> FramingRequirement {        let lower = framing.lowercased()        if !seated || category == .balances || lower.contains("feet") || lower.contains("full body") {            return .fullBodyFeet        }        if category == .expressions || (lower.contains("face") && !lower.contains("upper body")) {            return .face        }        if lower.contains("both hands clearly") || lower.contains("two hands") {            return .bothHands        }        if category == .hands || lower.contains("one hand") || lower == "hand" {            return .oneHand        }        return .upperBody    }} enum HandSelection: String, Codable, CaseIterable, Identifiable, Sendable {    case either    case left    case right     var id: String { rawValue }     var title: String {        switch self {        case .either: "Either"        case .left: "Left"        case .right: "Right"        }    }     var phrase: String {        switch self {        case .either: "on one hand"        case .left: "on your left hand"        case .right: "on your right hand"        }    }} enum JudgingSupport: String, Codable, CaseIterable, Identifiable, Sendable {    case manual    case assisted    case auto     var id: String { rawValue }     var badgeTitle: String {        switch self {        case .manual: "Manual"        case .assisted: "Assisted"        case .auto: "Auto"        }    }     var detailText: String {        switch self {        case .manual: "Manual judging • host confirms result"        case .assisted: "Assisted judging • host confirms result"        case .auto: "Auto-evaluated • host can override"        }    }} enum ActionSelectionSource: String, Codable, Equatable, Sendable {    case catalog    case custom} enum ActionEvaluatorRegistry {    static let validatedAutoActionIDs: Set<String> = []    static let validatedAssistedActionIDs: Set<String> = []     static func support(for promptID: String, suggestedID: String? = nil) -> JudgingSupport {        let ids = Set([promptID, suggestedID].compactMap { $0 })        if !ids.isDisjoint(with: validatedAutoActionIDs) {            return .auto        }        if !ids.isDisjoint(with: validatedAssistedActionIDs) {            return .assisted        }        return .manual    }} struct Prompt: Identifiable, Codable, Equatable, Sendable {    static let currentCatalogVersion = 3    static let catalogSchemaVersion = 1     static func validateCatalog(_ prompts: [Prompt]) throws {        guard Set(prompts.map(\.id)).count == prompts.count,              Set(prompts.map(\.canonicalSuggestedID)).count == prompts.count else {            throw PartyError.message("Duplicate catalog identifiers.")        }        for prompt in prompts {            guard ActionCategory(rawValue: prompt.category) != nil,                  !prompt.id.isEmpty, !prompt.title.isEmpty, !prompt.action.isEmpty,                  !prompt.framing.isEmpty, !prompt.tags.isEmpty,                  prompt.sortOrder > 0, prompt.catalogVersion > 0,                  (1...5).contains(prompt.defaultFingers),                  prompt.strategy == "manual",                  !prompt.action.localizedCaseInsensitiveContains("simon says") else {                throw PartyError.message("Invalid catalog record: \(prompt.id)")            }            for count in 1...5 {                for hand in HandSelection.allCases {                    let text = prompt.sentence(fingers: count, hand: hand)                    guard !text.contains("{"), !text.contains("}") else {                        throw PartyError.message("Unresolved action parameters: \(prompt.id)")                    }                }            }        }    }     let id: String    let suggestedID: String?    let title: String    let action: String    let seated: Bool    let standingEligible: Bool    let framing: String    let framingRequirement: FramingRequirement    let category: String    let kind: ActionKind    let sortOrder: Int    let strategy: String    let validation: String    let responseDuration: Int    let supportsFingerParameters: Bool    let defaultFingers: Int    let defaultHand: HandSelection    let tags: [String]    let catalogVersion: Int    let fixedRepetitions: Int?    let sequenceSteps: [String]?     var actionCategory: ActionCategory {        ActionCategory.resolve(category)    }     var canonicalSuggestedID: String {        suggestedID ?? id    }     var judgingSupport: JudgingSupport {        ActionEvaluatorRegistry.support(for: id, suggestedID: suggestedID)    }     var shortFramingHint: String {        framingRequirement.shortHint    }     var comfortNote: String? {        actionCategory.comfortNote    }     func matchesID(_ candidate: String) -> Bool {        id == candidate || suggestedID == candidate    }     init(        id: String,        suggestedID: String? = nil,        title: String,        action: String,        seated: Bool,        standingEligible: Bool = true,        framing: String,        framingRequirement: FramingRequirement? = nil,        category: String,        kind: ActionKind? = nil,        sortOrder: Int = 0,        strategy: String,        validation: String,        responseDuration: Int,        supportsFingerParameters: Bool? = nil,        defaultFingers: Int? = nil,        defaultHand: HandSelection? = nil,        tags: [String] = [],        catalogVersion: Int = Prompt.currentCatalogVersion,        fixedRepetitions: Int? = nil,        sequenceSteps: [String]? = nil    ) {        let resolvedCategory = ActionCategory.resolve(category)        self.id = id        self.suggestedID = suggestedID        self.title = title        self.action = action        self.seated = seated        self.standingEligible = standingEligible        self.framing = framing        self.framingRequirement = framingRequirement ?? FramingRequirement.infer(from: framing, category: resolvedCategory, seated: seated)        self.category = category        self.kind = kind ?? {            switch resolvedCategory {            case .poses: .staticPose            case .hands: .handShape            case .balances: .balance            case .movements: .repeatedMovement            case .expressions: .facialAction            }        }()        self.sortOrder = sortOrder        self.strategy = strategy        self.validation = validation        self.responseDuration = responseDuration        self.supportsFingerParameters = supportsFingerParameters ?? (id == "fingers" || suggestedID == "hands.show_fingers" || action.contains("{count}"))        self.defaultFingers = min(5, max(1, defaultFingers ?? 3))        self.defaultHand = defaultHand ?? .either        self.tags = tags        self.catalogVersion = catalogVersion        self.fixedRepetitions = fixedRepetitions        self.sequenceSteps = sequenceSteps    }     private enum CodingKeys: String, CodingKey {        case id, suggestedID, title, action, seated, standingEligible, framing, framingRequirement        case category, kind, sortOrder, strategy, validation, responseDuration        case supportsFingerParameters, defaultFingers, defaultHand, tags, catalogVersion, fixedRepetitions, sequenceSteps    }     init(from decoder: Decoder) throws {        let container = try decoder.container(keyedBy: CodingKeys.self)        let id = try container.decode(String.self, forKey: .id)        let suggestedID = try container.decodeIfPresent(String.self, forKey: .suggestedID)        let title = try container.decode(String.self, forKey: .title)        let action = try container.decode(String.self, forKey: .action)        let seated = try container.decode(Bool.self, forKey: .seated)        let standingEligible = try container.decodeIfPresent(Bool.self, forKey: .standingEligible) ?? true        let framing = try container.decode(String.self, forKey: .framing)        let category = try container.decode(String.self, forKey: .category)        let resolvedCategory = ActionCategory.resolve(category)        let framingRequirement = try container.decodeIfPresent(FramingRequirement.self, forKey: .framingRequirement)            ?? FramingRequirement.infer(from: framing, category: resolvedCategory, seated: seated)        let kind = try container.decodeIfPresent(ActionKind.self, forKey: .kind) ?? {            switch resolvedCategory {            case .poses: .staticPose            case .hands: .handShape            case .balances: .balance            case .movements: .repeatedMovement            case .expressions: .facialAction            }        }()        let sortOrder = try container.decodeIfPresent(Int.self, forKey: .sortOrder) ?? 0        let strategy = try container.decodeIfPresent(String.self, forKey: .strategy) ?? "manual"        let validation = try container.decodeIfPresent(String.self, forKey: .validation) ?? "Unvalidated — host judgment required"        let responseDuration = try container.decodeIfPresent(Int.self, forKey: .responseDuration) ?? 6        let supportsFingerParameters = try container.decodeIfPresent(Bool.self, forKey: .supportsFingerParameters)            ?? (id == "fingers" || suggestedID == "hands.show_fingers" || action.contains("{count}"))        let defaultFingers = try container.decodeIfPresent(Int.self, forKey: .defaultFingers) ?? 3        guard (1...5).contains(defaultFingers) else {            throw DecodingError.dataCorruptedError(forKey: .defaultFingers, in: container, debugDescription: "Finger default must be 1–5.")        }        let defaultHand = try container.decodeIfPresent(HandSelection.self, forKey: .defaultHand) ?? .either        let tags = try container.decodeIfPresent([String].self, forKey: .tags) ?? []        let catalogVersion = try container.decodeIfPresent(Int.self, forKey: .catalogVersion) ?? Prompt.currentCatalogVersion         self.init(            id: id,            suggestedID: suggestedID,            title: title,            action: action,            seated: seated,            standingEligible: standingEligible,            framing: framing,            framingRequirement: framingRequirement,            category: category,            kind: kind,            sortOrder: sortOrder,            strategy: strategy,            validation: validation,            responseDuration: responseDuration,            supportsFingerParameters: supportsFingerParameters,            defaultFingers: defaultFingers,            defaultHand: defaultHand,            tags: tags,            catalogVersion: catalogVersion,            fixedRepetitions: try container.decodeIfPresent(Int.self, forKey: .fixedRepetitions),            sequenceSteps: try container.decodeIfPresent([String].self, forKey: .sequenceSteps)        )    }     static func fingerWord(for count: Int) -> String {        switch min(5, max(1, count)) {        case 1: "one"        case 2: "two"        case 3: "three"        case 4: "four"        default: "five"        }    }     func sentence(fingers: Int, hand: HandSelection = .either) -> String {        guard supportsFingerParameters else { return action }        let clampedCount = min(5, max(1, fingers))        let word = Self.fingerWord(for: clampedCount)        let noun = clampedCount == 1 ? "finger" : "fingers"        let handPhrase = hand.phrase         if action.contains("{count}") || action.contains("{hand}") {            var resolved = action                .replacingOccurrences(of: "{count} fingers", with: "\(word) \(noun)")                .replacingOccurrences(of: "{count} finger", with: "\(word) \(noun)")                .replacingOccurrences(of: "{count}", with: word)            if resolved.contains("{hand}") {                resolved = resolved.replacingOccurrences(of: "{hand}", with: handPhrase)            } else if resolved.contains("on one hand") {                resolved = resolved.replacingOccurrences(of: "on one hand", with: handPhrase)            } else if hand != .either {                resolved += " \(handPhrase)"            }            return resolved        }        return "hold up \(word) \(noun) \(handPhrase)"    }     func displaySentence(fingers: Int = 3, hand: HandSelection = .either) -> String {        let raw = sentence(fingers: fingers, hand: hand).trimmingCharacters(in: .whitespacesAndNewlines)        guard let first = raw.first else { return "" }        let capitalized = first.uppercased() + raw.dropFirst()        if capitalized.hasSuffix(".") || capitalized.hasSuffix("!") || capitalized.hasSuffix("?") {            return capitalized        }        return capitalized + "."    }     func parameterSummary(fingers: Int, hand: HandSelection) -> String? {        guard supportsFingerParameters else { return nil }        let clamped = min(5, max(1, fingers))        let countLabel = clamped == 1 ? "1 finger" : "\(clamped) fingers"        return "\(countLabel) • \(hand.title) hand"    }     func durationRecommendation(configuredSeconds: Int) -> String? {        guard configuredSeconds < responseDuration else { return nil }        return "Recommended response window: \(responseDuration)s+ (currently \(configuredSeconds)s)."    }} enum ChallengePhase: String, Codable, Sendable {    case setup    case framing    case ready    case preparing    case recordingLeadIn    case speaking    case responding    case evaluating    case recordingTail    case awaitingReview    case completed    case interrupted    case failure    case handoff    case finished     var active: Bool {        [.preparing, .recordingLeadIn, .speaking, .responding, .evaluating, .recordingTail].contains(self)    }     var title: String {        switch self {        case .setup: "Setup"        case .framing: "Framing & Consent"        case .ready: "Ready"        case .preparing: "Preparing"        case .recordingLeadIn: "Lead-in"        case .speaking: "Speaking"        case .responding: "Response Window"        case .evaluating: "Evaluating"        case .recordingTail: "Finalizing Clip"        case .awaitingReview: "Host Review"        case .completed: "Completed"        case .interrupted: "Interrupted"        case .failure: "Camera Issue"        case .handoff: "Pass the Phone"        case .finished: "Finished"        }    }} enum PerformedAction: String, Codable, Sendable {    case performed    case notPerformed    case unknown} enum Verdict: String, Codable, Sendable {    case correct    case wrong} enum SimonRuleEvaluator {    static func evaluate(simonSays: Bool, action: PerformedAction) -> Verdict? {        switch (simonSays, action) {        case (true, .performed), (false, .notPerformed): .correct        case (true, .notPerformed), (false, .performed): .wrong        case (_, .unknown): nil        }    }} enum PartyTurnScheduler {    static func hostIndex(playerIndex: Int, count: Int) -> Int {        guard count > 1 else { return 0 }        return (playerIndex - 1 + count) % count    }} struct VerdictChange: Codable, Equatable, Sendable {    let verdict: Verdict    let date: Date} struct ChallengeSnapshot: Codable, Equatable, Sendable {    let id: UUID    let sessionID: UUID    let player: Player    let host: Player    let promptID: String    let instruction: String    let simonSays: Bool    let fingers: Int    let responseDuration: Double    let created: Date    var delivery: DeliverySnapshot? = nil    var visualCue: VisualCue? = nil    var suggestedPromptID: String? = nil    var categoryID: String? = nil    var catalogVersion: Int? = nil    var actionTitle: String? = nil    var actionText: String? = nil    var hand: HandSelection? = nil    var actionSource: ActionSelectionSource? = nil    var judgingMode: JudgingSupport? = nil     var requiresAction: Bool { visualCue?.requiresAction ?? simonSays }    var includesSimonSays: Bool { visualCue == nil && simonSays }     var resolvedActionSummary: String {        if let actionTitle, !actionTitle.isEmpty {            if promptID == "fingers" || suggestedPromptID == "hands.show_fingers", let hand {                let noun = fingers == 1 ? "1 finger" : "\(fingers) fingers"                return "\(actionTitle) (\(noun), \(hand.title))"            }            return actionTitle        }        return instruction    }} struct Attempt: Identifiable, Codable, Equatable, Sendable {    var id: UUID { snapshot.id }    let snapshot: ChallengeSnapshot    var rawFile: String? = nil    var narrationFile: String? = nil    var exportFile: String? = nil    var speechStart: Double? = nil    var speechEnd: Double? = nil    var duration: Double? = nil    var cueTiming: CueTiming? = nil    var voided = false    var history: [VerdictChange] = []     var verdict: Verdict? { history.last?.verdict }} struct ParticipantPresentation: Equatable, Sendable {    let status: String    let countdown: Int?    let recording: Bool    let cue: VisualCue?}