diff --git a/StandLock/Views/BreakScreen/ActionArea.swift b/StandLock/Views/BreakScreen/ActionArea.swift index 55c0a03..c0ac38b 100644 --- a/StandLock/Views/BreakScreen/ActionArea.swift +++ b/StandLock/Views/BreakScreen/ActionArea.swift @@ -88,6 +88,13 @@ struct ActionArea: View { maxAttempts: maxAttempts, onDismiss: onDismiss ) + case .slotMachine(let reelCount, let maxAttempts): + SlotMachineDismissView( + palette: palette, + reelCount: reelCount, + maxAttempts: maxAttempts, + onDismiss: onDismiss + ) case .keyCombo(let duration): KeyComboDismissView( palette: palette, holdDuration: duration, @@ -606,6 +613,464 @@ private struct CrateOpeningDismissView: View { } } +// MARK: - Slot Machine + +private struct SlotMachineDismissView: View { + let palette: BreakPalette + let reelCount: Int + let maxAttempts: Int + let onDismiss: () -> Void + + private let symbolHeight: CGFloat = 48 + private let symbolSpacing: CGFloat = 4 + private let reelWidth: CGFloat = 100 + private let reelSpacing: CGFloat = 12 + private let visibleRows = 3 + private let stopDuration: CGFloat = 0.8 + + @State private var reels: [ReelState] + @State private var symbols: [[SlotSymbol]] + @State private var gamePhase: GamePhase = .idle + @State private var currentAttempt = 0 + @State private var usedAttempts: [Bool] + @State private var nearMiss = false + @State private var winGlow = false + @State private var loseFlash: Set = [] + @State private var autoStopTasks: [Task] = [] + @State private var fallbackInput = "" + private let fallbackPhrase = "I prefer sitting anyway" + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + private var symbolStride: CGFloat { symbolHeight + symbolSpacing } + private var symbolCount: Int { SlotSymbol.allCases.count } + private var activeReelCount: Int { currentAttempt >= maxAttempts - 1 ? 1 : reelCount } + private var viewportHeight: CGFloat { CGFloat(visibleRows) * symbolStride - symbolSpacing } + private var centerAlignOffset: CGFloat { viewportHeight / 2 - symbolHeight / 2 } + + private struct ReelState { + var phase: ReelPhase = .idle + var baseOffset: CGFloat + var speed: CGFloat + var spinStart: Date = .distantPast + var stopStart: Date = .distantPast + var stopFromOffset: CGFloat = 0 + var targetOffset: CGFloat = 0 + var resultIndex: Int = 0 + } + + private enum ReelPhase: Equatable { + case idle, spinning, stopping, stopped + } + + private enum GamePhase: Equatable { + case idle, spinning, evaluating, result, fallback + } + + private enum SlotSymbol: CaseIterable, Equatable { + case skip, cross, skull, runner, bone, chair + + var label: String { + switch self { + case .skip: "Skip" + case .cross: "\u{2715}" + case .skull: "\u{1F480}" + case .runner: "\u{1F3C3}" + case .bone: "\u{1F9B4}" + case .chair: "\u{1FA91}" + } + } + + var isWin: Bool { self == .skip } + } + + init(palette: BreakPalette, reelCount: Int, maxAttempts: Int, onDismiss: @escaping () -> Void) { + self.palette = palette + self.reelCount = reelCount + self.maxAttempts = maxAttempts + self.onDismiss = onDismiss + + let initial: CGFloat = (CGFloat(3) * 52 - 4) / 2 - 24 + + self._reels = State(initialValue: (0.. some View { + VStack(spacing: 8) { + reelViewport(index: index, date: date) + reelStopButton(index: index) + } + } + + private func reelViewport(index: Int, date: Date) -> some View { + let offset = reelOffset(for: index, at: date) + + let rawFirst = (-offset - symbolHeight) / symbolStride + let rawLast = (viewportHeight - offset) / symbolStride + let first = max(0, Int(floor(rawFirst)) - 2) + let last = max(first, Int(ceil(rawLast)) + 2) + + return ZStack { + ForEach(first...last, id: \.self) { i in + symbolCell(symbols[index][i % symbolCount]) + .offset(y: offset - centerAlignOffset + CGFloat(i) * symbolStride) + } + + RoundedRectangle(cornerRadius: 8) + .strokeBorder(palette.accent, lineWidth: 2.5) + .frame(width: reelWidth + 8, height: symbolHeight + 6) + } + .frame(width: reelWidth, height: viewportHeight) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .fill(winGlow ? palette.accent.opacity(0.3) : + loseFlash.contains(index) ? Color.red.opacity(0.3) : .clear) + ) + .animation(.easeInOut(duration: 0.3), value: winGlow) + .animation(.easeInOut(duration: 0.3), value: loseFlash) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(palette.paperEdge, lineWidth: 1) + ) + } + + private func reelOffset(for index: Int, at date: Date) -> CGFloat { + let reel = reels[index] + switch reel.phase { + case .idle, .stopped: + return reel.baseOffset + case .spinning: + let elapsed = CGFloat(date.timeIntervalSince(reel.spinStart)) + return reel.baseOffset - elapsed * reel.speed + case .stopping: + let elapsed = CGFloat(date.timeIntervalSince(reel.stopStart)) + let t = min(elapsed / stopDuration, 1.0) + let eased = 1 - pow(1 - t, 3) + return reel.stopFromOffset + (reel.targetOffset - reel.stopFromOffset) * eased + } + } + + private func reelStopButton(index: Int) -> some View { + let active = reels[index].phase == .spinning + + return Button { + stopReel(index) + } label: { + VStack(spacing: 2) { + Text("Stop") + .font(BreakTypography.label(size: 13, weight: .medium)) + .foregroundStyle(palette.ink) + Rectangle() + .fill(palette.ink) + .frame(height: 1) + } + .fixedSize() + } + .buttonStyle(.plain) + .disabled(!active) + .opacity(gamePhase == .spinning ? (active ? 1 : 0.3) : 0) + .animation(.easeInOut(duration: 0.2), value: active) + .animation(.easeInOut(duration: 0.2), value: gamePhase) + } + + private func symbolCell(_ symbol: SlotSymbol) -> some View { + RoundedRectangle(cornerRadius: 6) + .fill(symbol.isWin ? palette.accent : Color.red.opacity(0.55)) + .frame(width: reelWidth - 8, height: symbolHeight) + .overlay( + RoundedRectangle(cornerRadius: 6) + .strokeBorder( + symbol.isWin ? palette.accent.opacity(0.3) : Color.red.opacity(0.15), + lineWidth: 1 + ) + ) + .overlay( + Text(symbol.label) + .font(BreakTypography.label(size: symbol.isWin ? 13 : 18, weight: .medium)) + .foregroundStyle(.white) + ) + } + + @ViewBuilder + private var fallbackContent: some View { + Text(fallbackPhrase) + .font(BreakTypography.label(size: 16, weight: .bold)) + .foregroundStyle(palette.accent) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(palette.accent.opacity(0.1)) + ) + + TextField("", text: $fallbackInput) + .textFieldStyle(.plain) + .font(BreakTypography.label(size: 14)) + .foregroundStyle(palette.ink) + .multilineTextAlignment(.center) + .padding(8) + .background( + RoundedRectangle(cornerRadius: 6) + .strokeBorder(palette.paperEdge, lineWidth: 1) + ) + .frame(width: 250) + + if fallbackInput.trimmingCharacters(in: .whitespaces).caseInsensitiveCompare(fallbackPhrase) == .orderedSame { + Button(action: onDismiss) { + VStack(spacing: 4) { + Text("Fine, go \u{2192}") + .font(BreakTypography.label(size: 14, weight: .medium)) + .foregroundStyle(palette.ink) + Rectangle() + .fill(palette.ink) + .frame(height: 1) + } + .fixedSize() + } + .buttonStyle(.plain) + .transition(.opacity) + } + + Text("We both know standing would\u{2019}ve been easier.") + .font(BreakTypography.label(size: 11)) + .foregroundStyle(palette.inkFaint) + } + + // MARK: Actions + + private func speedFactor(for attempt: Int) -> CGFloat { + switch attempt { + case 0: return 1.2 + case 1: return 0.9 + default: return 0.70 + } + } + + private func startSpin() { + let count = activeReelCount + for i in 0.. 1 && skipCount == count - 1 + + gamePhase = .result + let losing = (0..= maxAttempts { + gamePhase = .fallback + return + } + gamePhase = .idle + } + } + } +} + private struct IndicatorTriangle: Shape { func path(in rect: CGRect) -> Path { var path = Path() diff --git a/StandLockKit/Sources/Coordination/BreakCoordinator.swift b/StandLockKit/Sources/Coordination/BreakCoordinator.swift index 0a26b1a..b71b6a1 100644 --- a/StandLockKit/Sources/Coordination/BreakCoordinator.swift +++ b/StandLockKit/Sources/Coordination/BreakCoordinator.swift @@ -130,7 +130,8 @@ public final class BreakCoordinator: BreakCoordinating { guard var event = currentBreak else { return } event.outcome = .skipped if let scheduleID = currentBreak?.scheduleId { - escalationTiers[scheduleID, default: 0] = min(escalationTiers[scheduleID, default: 0] + 1, 4) + let maxTier = (currentSchedule?.disciplineLevel.enforcementPolicy(preferences: preferences).tiers.count ?? 5) - 1 + escalationTiers[scheduleID, default: 0] = min(escalationTiers[scheduleID, default: 0] + 1, maxTier) } locker.dismissOverlay() statistics.breaksSkipped += 1 @@ -146,7 +147,8 @@ public final class BreakCoordinator: BreakCoordinating { guard var event = currentBreak else { return } event.outcome = .escaped if let scheduleID = currentBreak?.scheduleId { - escalationTiers[scheduleID, default: 0] = min(escalationTiers[scheduleID, default: 0] + 1, 4) + let maxTier = (currentSchedule?.disciplineLevel.enforcementPolicy(preferences: preferences).tiers.count ?? 5) - 1 + escalationTiers[scheduleID, default: 0] = min(escalationTiers[scheduleID, default: 0] + 1, maxTier) } locker.dismissOverlay() statistics.breaksEscaped += 1 diff --git a/StandLockKit/Sources/StandLockCore/Models/DisciplineLevel.swift b/StandLockKit/Sources/StandLockCore/Models/DisciplineLevel.swift index a00b1b9..23d835e 100644 --- a/StandLockKit/Sources/StandLockCore/Models/DisciplineLevel.swift +++ b/StandLockKit/Sources/StandLockCore/Models/DisciplineLevel.swift @@ -37,7 +37,8 @@ extension DisciplineLevel { EnforcementTier(skipDelay: 5, dismissMechanism: .button), EnforcementTier(skipDelay: 10, dismissMechanism: .findButton(count: 8, attempts: 3)), EnforcementTier(skipDelay: 12, dismissMechanism: .crateOpening(slotCount: 12, maxAttempts: 3)), - EnforcementTier(skipDelay: 15, dismissMechanism: .typePhrase(phrase: "My legs are decorative", requiresConfirmation: false)), + EnforcementTier(skipDelay: 14, dismissMechanism: .slotMachine(reelCount: 3, maxAttempts: 3)), + EnforcementTier(skipDelay: 16, dismissMechanism: .typePhrase(phrase: "My legs are decorative", requiresConfirmation: false)), ]) case .firm: let phrase = preferences.firmEscapePhrase @@ -45,7 +46,7 @@ extension DisciplineLevel { return EnforcementPolicy(tiers: [ EnforcementTier(skipDelay: base, dismissMechanism: .typePhrase(phrase: phrase, requiresConfirmation: false)), EnforcementTier(skipDelay: base + 5, dismissMechanism: .typePhrase(phrase: phrase, requiresConfirmation: false)), - EnforcementTier(skipDelay: base + 10, dismissMechanism: .findButton(count: 8, attempts: 3)), + EnforcementTier(skipDelay: base + 10, dismissMechanism: .slotMachine(reelCount: 3, maxAttempts: 3)), EnforcementTier(skipDelay: base + 15, dismissMechanism: .typePhrase(phrase: phrase + " I really mean it", requiresConfirmation: true)), EnforcementTier(skipDelay: base + 20, dismissMechanism: .roastChallenge(sentenceCount: 3)), ]) diff --git a/StandLockKit/Sources/StandLockCore/Models/EnforcementPolicy.swift b/StandLockKit/Sources/StandLockCore/Models/EnforcementPolicy.swift index 60edd07..da0c9ad 100644 --- a/StandLockKit/Sources/StandLockCore/Models/EnforcementPolicy.swift +++ b/StandLockKit/Sources/StandLockCore/Models/EnforcementPolicy.swift @@ -5,6 +5,7 @@ public enum DismissMechanism: Sendable, Equatable { case typePhrase(phrase: String, requiresConfirmation: Bool) case findButton(count: Int, attempts: Int) case crateOpening(slotCount: Int, maxAttempts: Int) + case slotMachine(reelCount: Int, maxAttempts: Int) case keyCombo(duration: TimeInterval) case roastChallenge(sentenceCount: Int) } diff --git a/StandLockKit/Tests/CoordinationTests/CoordinationTests.swift b/StandLockKit/Tests/CoordinationTests/CoordinationTests.swift index f12f1bc..ed1517d 100644 --- a/StandLockKit/Tests/CoordinationTests/CoordinationTests.swift +++ b/StandLockKit/Tests/CoordinationTests/CoordinationTests.swift @@ -508,11 +508,16 @@ struct BreakCoordinatorTests { try? await Task.sleep(for: .milliseconds(300)) #expect(locker.lastEscalationTier == 4) - // Cap at 4 (gentle has 5 tiers, index 0-4) coordinator.skipActiveBreak() scheduler.nextBreakTimeToReturn = Date().addingTimeInterval(0.05) try? await Task.sleep(for: .milliseconds(300)) - #expect(locker.lastEscalationTier == 4) + #expect(locker.lastEscalationTier == 5) + + // Cap at 5 (gentle has 6 tiers, index 0-5) + coordinator.skipActiveBreak() + scheduler.nextBreakTimeToReturn = Date().addingTimeInterval(0.05) + try? await Task.sleep(for: .milliseconds(300)) + #expect(locker.lastEscalationTier == 5) coordinator.stop() } diff --git a/StandLockKit/Tests/StandLockCoreTests/StandLockCoreTests.swift b/StandLockKit/Tests/StandLockCoreTests/StandLockCoreTests.swift index f1cc2d2..beafe32 100644 --- a/StandLockKit/Tests/StandLockCoreTests/StandLockCoreTests.swift +++ b/StandLockKit/Tests/StandLockCoreTests/StandLockCoreTests.swift @@ -192,7 +192,7 @@ struct ScheduleModelTests { @Test func enforcementPolicyGentleTiers() { let policy = DisciplineLevel.gentle.enforcementPolicy(preferences: AppPreferences()) - #expect(policy.tiers.count == 5) + #expect(policy.tiers.count == 6) #expect(policy.tiers[0].dismissMechanism == .button) #expect(policy.tiers[0].skipDelay == 0) #expect(policy.tiers[1].dismissMechanism == .button) @@ -201,8 +201,10 @@ struct ScheduleModelTests { #expect(policy.tiers[2].skipDelay == 10) #expect(policy.tiers[3].dismissMechanism == .crateOpening(slotCount: 12, maxAttempts: 3)) #expect(policy.tiers[3].skipDelay == 12) - #expect(policy.tiers[4].dismissMechanism == .typePhrase(phrase: "My legs are decorative", requiresConfirmation: false)) - #expect(policy.tiers[4].skipDelay == 15) + #expect(policy.tiers[4].dismissMechanism == .slotMachine(reelCount: 3, maxAttempts: 3)) + #expect(policy.tiers[4].skipDelay == 14) + #expect(policy.tiers[5].dismissMechanism == .typePhrase(phrase: "My legs are decorative", requiresConfirmation: false)) + #expect(policy.tiers[5].skipDelay == 16) } @Test func enforcementPolicyFirmUsesPreferences() { @@ -211,7 +213,8 @@ struct ScheduleModelTests { #expect(policy.tiers.count == 5) #expect(policy.tiers[0].skipDelay == 20) #expect(policy.tiers[0].dismissMechanism == .typePhrase(phrase: "let me go", requiresConfirmation: false)) - #expect(policy.tiers[2].dismissMechanism == .findButton(count: 8, attempts: 3)) + #expect(policy.tiers[2].dismissMechanism == .slotMachine(reelCount: 3, maxAttempts: 3)) + #expect(policy.tiers[2].skipDelay == 30) #expect(policy.tiers[3].dismissMechanism == .typePhrase(phrase: "let me go I really mean it", requiresConfirmation: true)) #expect(policy.tiers[3].skipDelay == 35) #expect(policy.tiers[4].dismissMechanism == .roastChallenge(sentenceCount: 3)) @@ -235,7 +238,7 @@ struct ScheduleModelTests { @Test func enforcementPolicyTierClampsToRange() { let policy = DisciplineLevel.gentle.enforcementPolicy(preferences: AppPreferences()) let last = policy.tier(at: 99) - #expect(last == policy.tiers[4]) + #expect(last == policy.tiers[5]) let first = policy.tier(at: -1) #expect(first == policy.tiers[0]) } @@ -245,6 +248,8 @@ struct ScheduleModelTests { let last = policy.tier(at: 99) #expect(last == policy.tiers[4]) #expect(last.dismissMechanism == .roastChallenge(sentenceCount: 3)) + let first = policy.tier(at: -1) + #expect(first == policy.tiers[0]) } // MARK: - Schedule with Progressive Enforcement