MyApp/GameModels.swift
20 KB · 591 lines · swift · Download from the repository page
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?}