From 174d7cc3d05ce4fd7c462287ce87a8a611f491c9 Mon Sep 17 00:00:00 2001 From: yagizdo Date: Fri, 5 Jun 2026 13:05:04 +0300 Subject: [PATCH 1/4] feat: add slot machine challenge with progressive difficulty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce slot machine dismiss mechanism as a new enforcement tier. Three reels on first two attempts, single reel on the last attempt. Speed decreases each attempt (1.2x → 0.9x → 0.7x) so timing gets easier with practice. If all three attempts fail, falls back to a phrase-typing escape ("I prefer sitting anyway"). Added to gentle tier 4 and firm tier 2. Uniform reel speed and reduced overshoot (0.5× symbol stride) make the stop timing feel responsive rather than random. --- StandLock/Views/BreakScreen/ActionArea.swift | 464 ++++++++++++++++++ .../Models/DisciplineLevel.swift | 5 +- .../Models/EnforcementPolicy.swift | 1 + .../StandLockCoreTests.swift | 12 +- 4 files changed, 475 insertions(+), 7 deletions(-) diff --git a/StandLock/Views/BreakScreen/ActionArea.swift b/StandLock/Views/BreakScreen/ActionArea.swift index 55c0a03..f168798 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,463 @@ 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 roundNumber = 0 + @State private var nearMiss = false + @State private var winGlow = false + @State private var loseFlash: Set = [] + @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.. 0 { + return roundHeaders[min(roundNumber - 1, roundHeaders.count - 1)] + } + return headers[min(currentAttempt, headers.count - 1)] + case .evaluating: + return headers[min(currentAttempt, headers.count - 1)] + case .result: + let count = activeReelCount + let allSkip = (0.. 0 { + return roundSubtitles[min(roundNumber - 1, roundSubtitles.count - 1)] + } + return subtitles[min(currentAttempt, subtitles.count - 1)] + } + + // MARK: Computed + + private var anyAnimating: Bool { + reels.contains { $0.phase == .spinning || $0.phase == .stopping } + } + + // MARK: Body + + var body: some View { + VStack(spacing: 12) { + Text(headerText) + .font(BreakTypography.label(size: 14, weight: .medium)) + .foregroundStyle(palette.ink) + .contentTransition(.interpolate) + .animation(.easeInOut(duration: 0.2), value: gamePhase) + .animation(.easeInOut(duration: 0.2), value: currentAttempt) + .animation(.easeInOut(duration: 0.2), value: roundNumber) + + if gamePhase == .fallback { + fallbackContent + } else { + HStack(spacing: 6) { + ForEach(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 = 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/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/StandLockCoreTests/StandLockCoreTests.swift b/StandLockKit/Tests/StandLockCoreTests/StandLockCoreTests.swift index f1cc2d2..c0e33ad 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,7 @@ 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[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 +237,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]) } From f3676c37835f5f865c7e7616603cbb010bf1b364 Mon Sep 17 00:00:00 2001 From: yagizdo Date: Sat, 6 Jun 2026 21:23:17 +0300 Subject: [PATCH 2/4] fix(slot-machine): address PR review findings - C1: replace hardcoded escalation cap 4 with dynamic tiers.count-1 in BreakCoordinator, making Gentle tier 5 reachable - H1: add per-reel 15s auto-stop timer in startSpin to prevent permanent livelock - M1: add missing skipDelay==30 assertion for Firm tier[2] in tests - L1: remove roundNumber state and all dead roundHeaders/roundSubtitles branches - L2: guard ForEach range against inversion with max(first, ...) - L4: add lower-bound clamp test for Firm policy - update CoordinationTests cap assertion from 4->5 to reflect 6-tier Gentle policy --- StandLock/Views/BreakScreen/ActionArea.swift | 26 ++++++++----------- .../Coordination/BreakCoordinator.swift | 6 +++-- .../CoordinationTests/CoordinationTests.swift | 9 +++++-- .../StandLockCoreTests.swift | 3 +++ 4 files changed, 25 insertions(+), 19 deletions(-) diff --git a/StandLock/Views/BreakScreen/ActionArea.swift b/StandLock/Views/BreakScreen/ActionArea.swift index f168798..a7f27c4 100644 --- a/StandLock/Views/BreakScreen/ActionArea.swift +++ b/StandLock/Views/BreakScreen/ActionArea.swift @@ -633,7 +633,6 @@ private struct SlotMachineDismissView: View { @State private var gamePhase: GamePhase = .idle @State private var currentAttempt = 0 @State private var usedAttempts: [Bool] - @State private var roundNumber = 0 @State private var nearMiss = false @State private var winGlow = false @State private var loseFlash: Set = [] @@ -705,15 +704,9 @@ private struct SlotMachineDismissView: View { private let lossMessages = ["The house always wins", "Two out of three ain\u{2019}t... well, it IS bad here.", "Impressive. Not a single one."] private let nearMissMessage = "Soooo close. The universe has a cruel sense of humor." private let winMessages = ["Genuinely impressive. Fine, go.", "Took you two tries but okay.", "Jackpot. Ugh."] - private let roundHeaders = ["Out of coins", "Back for more punishment?", "At this point you\u{2019}ve spent more energy gambling than a break would take"] - private let roundSubtitles = ["The machine wins this round. Maybe just... stand up?", "You know standing takes 30 seconds, right?", "This is getting sad"] - private var headerText: String { switch gamePhase { case .idle, .spinning: - if roundNumber > 0 { - return roundHeaders[min(roundNumber - 1, roundHeaders.count - 1)] - } return headers[min(currentAttempt, headers.count - 1)] case .evaluating: return headers[min(currentAttempt, headers.count - 1)] @@ -734,10 +727,7 @@ private struct SlotMachineDismissView: View { } private var subtitleText: String { - if roundNumber > 0 { - return roundSubtitles[min(roundNumber - 1, roundSubtitles.count - 1)] - } - return subtitles[min(currentAttempt, subtitles.count - 1)] + subtitles[min(currentAttempt, subtitles.count - 1)] } // MARK: Computed @@ -756,7 +746,6 @@ private struct SlotMachineDismissView: View { .contentTransition(.interpolate) .animation(.easeInOut(duration: 0.2), value: gamePhase) .animation(.easeInOut(duration: 0.2), value: currentAttempt) - .animation(.easeInOut(duration: 0.2), value: roundNumber) if gamePhase == .fallback { fallbackContent @@ -782,12 +771,11 @@ private struct SlotMachineDismissView: View { .foregroundStyle(palette.inkFaint) .contentTransition(.interpolate) .animation(.easeInOut(duration: 0.2), value: currentAttempt) - .animation(.easeInOut(duration: 0.2), value: roundNumber) if gamePhase == .idle { Button(action: startSpin) { VStack(spacing: 4) { - Text(currentAttempt == 0 && roundNumber == 0 ? "Spin \u{2192}" : "Try again \u{2192}") + Text(currentAttempt == 0 ? "Spin \u{2192}" : "Try again \u{2192}") .font(BreakTypography.label(size: 14, weight: .medium)) .foregroundStyle(palette.ink) Rectangle() @@ -819,7 +807,7 @@ private struct SlotMachineDismissView: View { let rawFirst = (-offset - symbolHeight) / symbolStride let rawLast = (viewportHeight - offset) / symbolStride let first = max(0, Int(floor(rawFirst)) - 2) - let last = Int(ceil(rawLast)) + 2 + let last = max(first, Int(ceil(rawLast)) + 2) return ZStack { ForEach(first...last, id: \.self) { i in @@ -994,6 +982,14 @@ private struct SlotMachineDismissView: View { speed: 220 * factor, spinStart: now ) + let reelIndex = i + Task { @MainActor in + try? await Task.sleep(for: .seconds(15)) + guard !Task.isCancelled else { return } + if reels[reelIndex].phase == .spinning { + stopReel(reelIndex) + } + } } gamePhase = .spinning 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/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 c0e33ad..beafe32 100644 --- a/StandLockKit/Tests/StandLockCoreTests/StandLockCoreTests.swift +++ b/StandLockKit/Tests/StandLockCoreTests/StandLockCoreTests.swift @@ -214,6 +214,7 @@ struct ScheduleModelTests { #expect(policy.tiers[0].skipDelay == 20) #expect(policy.tiers[0].dismissMechanism == .typePhrase(phrase: "let me go", requiresConfirmation: false)) #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)) @@ -247,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 From 79a5f7ac624d1ea28ad6bf29b23c0ada5c2a88aa Mon Sep 17 00:00:00 2001 From: yagizdo Date: Sat, 6 Jun 2026 22:11:55 +0300 Subject: [PATCH 3/4] fix(slot-machine): cancel auto-stop tasks on re-spin; surface slot machine first for QA --- StandLock/Views/BreakScreen/ActionArea.swift | 7 ++++++- .../Sources/StandLockCore/Models/DisciplineLevel.swift | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/StandLock/Views/BreakScreen/ActionArea.swift b/StandLock/Views/BreakScreen/ActionArea.swift index a7f27c4..c0ac38b 100644 --- a/StandLock/Views/BreakScreen/ActionArea.swift +++ b/StandLock/Views/BreakScreen/ActionArea.swift @@ -636,6 +636,7 @@ private struct SlotMachineDismissView: View { @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 @@ -972,6 +973,9 @@ private struct SlotMachineDismissView: View { return } + autoStopTasks.forEach { $0.cancel() } + autoStopTasks = [] + let now = Date() let factor = speedFactor(for: currentAttempt) @@ -983,13 +987,14 @@ private struct SlotMachineDismissView: View { spinStart: now ) let reelIndex = i - Task { @MainActor in + let task = Task { @MainActor in try? await Task.sleep(for: .seconds(15)) guard !Task.isCancelled else { return } if reels[reelIndex].phase == .spinning { stopReel(reelIndex) } } + autoStopTasks.append(task) } gamePhase = .spinning diff --git a/StandLockKit/Sources/StandLockCore/Models/DisciplineLevel.swift b/StandLockKit/Sources/StandLockCore/Models/DisciplineLevel.swift index 23d835e..e8a8a1e 100644 --- a/StandLockKit/Sources/StandLockCore/Models/DisciplineLevel.swift +++ b/StandLockKit/Sources/StandLockCore/Models/DisciplineLevel.swift @@ -33,7 +33,7 @@ extension DisciplineLevel { switch self { case .gentle: return EnforcementPolicy(tiers: [ - EnforcementTier(skipDelay: 0, dismissMechanism: .button), + EnforcementTier(skipDelay: 0, dismissMechanism: .slotMachine(reelCount: 3, maxAttempts: 3)), EnforcementTier(skipDelay: 5, dismissMechanism: .button), EnforcementTier(skipDelay: 10, dismissMechanism: .findButton(count: 8, attempts: 3)), EnforcementTier(skipDelay: 12, dismissMechanism: .crateOpening(slotCount: 12, maxAttempts: 3)), From 7c0d5c87b509b5a51e87292a0ff9ce3d3aa4769e Mon Sep 17 00:00:00 2001 From: yagizdo Date: Sat, 6 Jun 2026 22:20:50 +0300 Subject: [PATCH 4/4] revert(discipline): restore gentle tier-0 to button after slot machine QA --- StandLockKit/Sources/StandLockCore/Models/DisciplineLevel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/StandLockKit/Sources/StandLockCore/Models/DisciplineLevel.swift b/StandLockKit/Sources/StandLockCore/Models/DisciplineLevel.swift index e8a8a1e..23d835e 100644 --- a/StandLockKit/Sources/StandLockCore/Models/DisciplineLevel.swift +++ b/StandLockKit/Sources/StandLockCore/Models/DisciplineLevel.swift @@ -33,7 +33,7 @@ extension DisciplineLevel { switch self { case .gentle: return EnforcementPolicy(tiers: [ - EnforcementTier(skipDelay: 0, dismissMechanism: .slotMachine(reelCount: 3, maxAttempts: 3)), + EnforcementTier(skipDelay: 0, dismissMechanism: .button), EnforcementTier(skipDelay: 5, dismissMechanism: .button), EnforcementTier(skipDelay: 10, dismissMechanism: .findButton(count: 8, attempts: 3)), EnforcementTier(skipDelay: 12, dismissMechanism: .crateOpening(slotCount: 12, maxAttempts: 3)),