← poker-tutor
import Foundation
/// What the player may do right now, and why.
///
/// The paid half of this is Apple's answer, not ours, and is passed in. The free half is counted by
/// `TrialTally`.
public enum Entitlement: Sendable, Equatable {
/// A current subscription. Everything is open.
case subscribed
/// Not paying, with free hands still to play.
case trial(handsLeft: Int)
/// Not paying, and the free hands are gone.
case lapsed
/// Whether a new hand may be dealt.
public var mayPlay: Bool {
switch self {
case .subscribed: return true
case .trial(let left): return left > 0
case .lapsed: return false
}
}
/// Whether to show what somebody has already played. Always true: hands somebody played are
/// theirs, and holding their own history hostage to a payment is a thing this app does not do.
/// Only new hands are gated.
public var maySeeHistory: Bool { true }
}
/// Somewhere a single number survives the app closing.
///
/// A protocol rather than `UserDefaults` directly so the count can be tested without touching the
/// machine's real preferences, and so the store can be swapped for the keychain later without
/// anything above here noticing — see `TrialTally` for why that swap may eventually be wanted.
public protocol TallyStore: AnyObject {
func integer(forKey key: String) -> Int
func set(_ value: Int, forKey key: String)
}
/// `UserDefaults` already has exactly this shape.
extension UserDefaults: TallyStore {}
/// A `TallyStore` that forgets everything when it goes out of scope. For tests.
public final class InMemoryTally: TallyStore {
private var values: [String: Int] = [:]
public init() {}
public func integer(forKey key: String) -> Int { values[key] ?? 0 }
public func set(_ value: Int, forKey key: String) { values[key] = value }
}
/// How many hands somebody has played against their free allowance.
///
/// Kept as its own number rather than counted off the saved hands (Ken, 2026-08-09). The reason
/// that matters: `HistoryStore.clear` — the "Start over" button — throws away every saved hand, and
/// a count derived from those hands would reset with them. Start over is for resetting your poker
/// record. It is not for resetting your bill, and this tally deliberately survives it.
///
/// The cost of keeping it separate is that it can drift from the history: lose a write, restore a
/// backup, and the tally and the hands disagree. That is accepted. When they disagree the tally is
/// the one that decides whether somebody may play, because it is the one that cannot be cleared
/// from inside the app.
///
/// What it does NOT survive is deleting the app, which takes `UserDefaults` with it. If free hands
/// being farmed by reinstalling ever becomes a real problem, move the storage to the keychain —
/// that survives deletion — by writing a `TallyStore` that reads and writes there. Nothing above
/// this line would change. It is not worth doing pre-emptively for a $9.99 subscription.
public final class TrialTally {
private let store: TallyStore
private let key: String
public init(store: TallyStore = UserDefaults.standard, key: String = AppVariant.current.tallyKey) {
self.store = store
self.key = key
}
/// How many hands have been spent. Never goes down.
public var handsPlayed: Int { max(0, store.integer(forKey: key)) }
/// Count a hand that has been played.
///
/// Takes the record rather than being a bare increment so there is exactly one place that
/// decides which hands count, and the caller cannot get it wrong by forgetting.
@discardableResult
public func record(_ hand: HandRecord) -> Int {
guard FreeTrial.counts(hand) else { return handsPlayed }
let next = handsPlayed + 1
store.set(next, forKey: key)
return next
}
/// What somebody is entitled to right now.
public func entitlement(subscribed: Bool) -> Entitlement {
FreeTrial.entitlement(subscribed: subscribed, handsPlayed: handsPlayed)
}
/// Wipe the count. Deliberately NOT called by "Start over" — this exists for tests, and for a
/// support reply to somebody who was charged wrongly.
public func reset() { store.set(0, forKey: key) }
}
/// The rule: how many hands are free, which ones count, and when to say something about it.
public enum FreeTrial {
/// The free allowance.
///
/// Set to 100 by Ken on 2026-09-11, down from 300, with the shape of the free product changed
/// at the same time. The free product is no longer "a settled rating" — that needs
/// `Coach.handsToSettle` hands and more — it is "your most common habit, named": see
/// `FreeView`. A hundred hands is enough for a loud habit to clear its own floor (playing too
/// many hands, limping, folding the big blind), and the assessment's forty hands are the
/// designed way to get there: assessment → the habit named → sixty more hands of table, drill
/// or re-check → the paywall.
///
/// It is a product decision, it lives in one place, and every test derives from it rather than
/// writing the number out.
public static let freeHands = 100
/// Whether a hand eats into the free allowance. Every hand does.
///
/// The assessment used to be exempt as "the demonstration". With the allowance at a hundred
/// that would have made the free tier 140 hands, and "play 100 hands" is what was asked for;
/// the assessment is forty hands of the product, and its report is where the free habit is
/// first named. Drills and re-checks count too: an uncounted drill would be an unlimited free
/// table for anybody who noticed.
public static func counts(_ hand: HandRecord) -> Bool { true }
/// How many free hands are left, never below zero.
public static func handsLeft(played: Int) -> Int { max(0, freeHands - max(0, played)) }
/// What somebody is entitled to, given Apple's answer about their subscription and the tally.
///
/// A subscriber is `.subscribed` regardless of the count — somebody who subscribes, lapses and
/// comes back does not find their old hands held against them.
public static func entitlement(subscribed: Bool, handsPlayed: Int) -> Entitlement {
if subscribed { return .subscribed }
let left = handsLeft(played: handsPlayed)
return left > 0 ? .trial(handsLeft: left) : .lapsed
}
/// When to warn somebody the free hands are running out, so the paywall is not the first they
/// hear of the price. Nobody enjoys being stopped mid-sitting by a bill.
public static let warnWithHandsLeft = 20
/// Whether to say something about the allowance now. False for a subscriber, false while the
/// end is far off, and false once it has been hit — at that point the paywall is the message
/// and a warning stacked on top of it is noise.
public static func shouldWarn(_ entitlement: Entitlement) -> Bool {
if case .trial(let left) = entitlement { return left <= warnWithHandsLeft }
return false
}
}