diff --git a/Sources/PokeTokenBar/Core/CompanionModel.swift b/Sources/PokeTokenBar/Core/CompanionModel.swift index 4977d8b2..93ef1c35 100644 --- a/Sources/PokeTokenBar/Core/CompanionModel.swift +++ b/Sources/PokeTokenBar/Core/CompanionModel.swift @@ -103,6 +103,18 @@ enum PokemonBalance { /// 알 부화 임계 — 이만큼 토큰을 써야 알이 깨진다(즉시 부화 대신 기대감). 초과분은 부화체 성장에 이월. static let eggHatchThreshold = 5_000_000 static let repeatGrowthMultiplier = 2 + /// 일일 스트릭 인정 최소 토큰 (기본 10M). + static let defaultDailyStreakThreshold = 10_000_000 + + /// 연속 코딩 일수에 따른 성장 속도 부스트 배율. + /// 3일: 1.10x, 7일: 1.25x, 14일: 1.35x, 30일: 1.50x. + static func streakMultiplier(for days: Int) -> Double { + if days >= 30 { return 1.50 } + if days >= 14 { return 1.35 } + if days >= 7 { return 1.25 } + if days >= 3 { return 1.10 } + return 1.0 + } static func graduationTotal(_ rarity: Rarity) -> Int { switch rarity { @@ -610,6 +622,37 @@ private extension KeyedDecodingContainer { } } +/// 연속 코딩 스트릭 상태 (Application Support JSON 영속). +struct StreakState: Codable, Sendable, Equatable { + var days: Int = 0 + var lastDay: String = "" + + /// 표시 및 배율 계산에 사용하는 유효 연속 일수. + /// 어제 달성 후 오늘 아직 미달성이어도 당일 중에는 어제 스트릭을 유예(morning tolerance)하여 유지한다. + /// 시차 이동(동->서 등)으로 `todayDate <= lastDay`인 경우에도 확보한 스트릭을 보존한다. + func effectiveDays(todayDate: String) -> Int { + guard days > 0, !lastDay.isEmpty else { return 0 } + if todayDate <= lastDay { + return days + } + if let diff = LocalUsageReader.dayDifference(from: lastDay, to: todayDate), diff == 1 { + return days + } + return 0 + } + + /// 당일 활성 토큰 임계 도달 시 호출. + mutating func recordActiveDay(_ todayDate: String) { + if !lastDay.isEmpty && todayDate <= lastDay { return } + if !lastDay.isEmpty, let diff = LocalUsageReader.dayDifference(from: lastDay, to: todayDate), diff == 1 { + days += 1 + } else { + days = 1 + } + lastDay = todayDate + } +} + /// 영속 상태(Application Support JSON). 포켓몬 전환 — 이전 커스텀 캐릭터 상태는 폐기(새로 시작). struct CompanionState: Codable, Sendable { // 토큰: 설치 이후만 측정 @@ -637,6 +680,8 @@ struct CompanionState: Codable, Sendable { /// 키는 `UsageProvider.id`를 그대로 사용한다. var claimedTodayTokensByProvider: [String: Int]? = nil var lastDate = "" + // 연속 코딩 스트릭 (일수 + 마지막 달성일) + var streak = StreakState() // 현재 포켓몬(없으면 알) var active: MonState? // 메뉴바와 플로팅 펫에 고정한 대표 종. nil = 현재 키우는 포켓몬(또는 알)을 그대로 따라간다. @@ -682,6 +727,7 @@ struct CompanionState: Codable, Sendable { claimedTodayTokensByProvider = nil } lastDate = c.lenient(String.self, forKey: .lastDate, default: "") + streak = c.lenient(StreakState.self, forKey: .streak, default: StreakState()) // active 손상(빈 pathIDs 등) → 알로 폴백하되 도감·인벤토리는 보존. active = c.lenientOptional(MonState.self, forKey: .active) representativeSpeciesID = c.lenientOptional(Int.self, forKey: .representativeSpeciesID) diff --git a/Sources/PokeTokenBar/Core/CompanionStore.swift b/Sources/PokeTokenBar/Core/CompanionStore.swift index 5cc470fd..866dc67b 100644 --- a/Sources/PokeTokenBar/Core/CompanionStore.swift +++ b/Sources/PokeTokenBar/Core/CompanionStore.swift @@ -148,15 +148,37 @@ final class CompanionStore { defaults.set(clamped, forKey: "shopDifficulty") } - /// 난이도를 반영한 알 부화 임계. + /// 일일 스트릭 인정 최소 토큰 (기본 10M). + var dailyStreakThreshold: Int { + defaults.object(forKey: "dailyStreakThreshold") as? Int ?? PokemonBalance.defaultDailyStreakThreshold + } + + func setDailyStreakThreshold(_ value: Int) { + let clamped = max(1, min(value, 1_000_000_000)) + defaults.set(clamped, forKey: "dailyStreakThreshold") + } + + /// 현재 유효 연속 코딩 일수. + var streakDays: Int { + state.streak.effectiveDays(todayDate: LocalUsageReader.todayKey()) + } + + /// 연속 코딩 스트릭에 따른 성장 배율 (3일 1.1x, 7일 1.25x, 14일 1.35x, 30일 1.5x). + var streakMultiplier: Double { + PokemonBalance.streakMultiplier(for: streakDays) + } + + /// 난이도 및 연속 스트릭 배율을 반영한 알 부화 임계. private var eggHatchThreshold: Int { - PokemonBalance.scaled(PokemonBalance.eggHatchThreshold, by: growthDifficulty) + let base = PokemonBalance.scaled(PokemonBalance.eggHatchThreshold, by: growthDifficulty) + return max(1, Int((Double(base) / streakMultiplier).rounded())) } - /// 난이도를 반영한 단계 임계. **`PokemonBalance.phaseThreshold` 를 직접 부르지 않는다** — + /// 난이도 및 연속 스트릭 배율을 반영한 단계 임계. **`PokemonBalance.phaseThreshold` 를 직접 부르지 않는다** — /// 배율을 빠뜨린 호출부가 생기면 그 경로만 조용히 기본 난이도로 돌아간다. private func stageThreshold(for mon: MonState) -> Int { - PokemonBalance.scaled(mon.phaseThreshold, by: growthDifficulty) + let base = PokemonBalance.scaled(mon.phaseThreshold, by: growthDifficulty) + return max(1, Int((Double(base) / streakMultiplier).rounded())) } /// 상점 표시·결제에 쓰는 실제 가격 — 기본가 × 상점 난이도. 미판매면 nil. @@ -182,6 +204,12 @@ final class CompanionStore { var growthMultiplier: Int? { state.active?.hasGrowthBoost == true ? PokemonBalance.repeatGrowthMultiplier : nil } + var streakBadgeText: String { + l.streakBadge(days: streakDays, multiplier: streakMultiplier) + } + var streakTooltipText: String { + l.streakTooltip(days: streakDays, multiplier: streakMultiplier) + } /// 메뉴바와 플로팅 펫이 그릴 대표 종과 색. nil 선택은 기존 동작(현재 개체/알)을 보존한다. /// 저장된 값이라 상시 렌더링 경로가 도감 전체를 다시 접거나 불필요한 상태를 관찰하지 않는다. @@ -533,15 +561,78 @@ final class CompanionStore { }) } + /// 구버전에서 업그레이드 시 이번 달 기록(monthDailyTotals)으로 스트릭을 초기 1회 시드해야 하는지 여부. + var needsStreakBootstrap: Bool { + state.streak.days == 0 && state.streak.lastDay.isEmpty + } + + /// 구버전에서 업그레이드 시 이번 달 기록(monthDailyTotals)으로 스트릭을 초기 1회 시드. + func bootstrapStreakIfNeeded(from series: [DailyUsage], todayDate: String) { + guard state.streak.days == 0, state.streak.lastDay.isEmpty, !series.isEmpty else { return } + let threshold = dailyStreakThreshold + let activeDates = Set(series.filter { $0.totalTokens >= threshold }.map(\.date)) + guard !activeDates.isEmpty else { return } + + // 오늘 이미 달성했으면 오늘부터 카운트 시작, 아니면 어제부터 + let checkDate: String + if activeDates.contains(todayDate) { + checkDate = todayDate + } else { + guard let yesterday = series.last(where: { $0.date < todayDate })?.date, + let diff = LocalUsageReader.dayDifference(from: yesterday, to: todayDate), diff == 1, + activeDates.contains(yesterday) else { + return + } + checkDate = yesterday + } + + var count = 0 + var cursor = checkDate + let lastAchievedDay = checkDate + let fmt = LocalUsageReader.localDayFormatter() + while activeDates.contains(cursor) { + count += 1 + guard let currentDate = fmt.date(from: cursor), + let prevDate = Calendar(identifier: .gregorian).date(byAdding: .day, value: -1, to: currentDate) else { + break + } + let prevString = fmt.string(from: prevDate) + if activeDates.contains(prevString) { + cursor = prevString + } else { + break + } + } + + if count > 0 { + state.streak.days = count + state.streak.lastDay = lastAchievedDay + save() + } + } + // MARK: 갱신 (AppDelegate 가 UsageStore 값으로 호출) func update(todayTokensByProvider: [String: Int], todayDate: String, monthTotal: Int, - burnTier: BurnTier, limitWarning: Bool, hasUsageData: Bool) { + burnTier: BurnTier, limitWarning: Bool, hasUsageData: Bool, + monthDailyTotals: [DailyUsage] = []) { let todayTokens = todayTokensByProvider.values.reduce(0, +) // `hasUsageData`는 표시용 snapshot 존재 여부이고, 이 map은 오늘 날짜가 확인된 // provider 데이터만 담는다. stale snapshot이나 today == nil carrier만 있는 refresh는 // ledger의 기준점을 움직일 수 있는 관측으로 취급하지 않는다. let hasCurrentProviderData = hasUsageData && !todayTokensByProvider.isEmpty + if hasCurrentProviderData { + if !monthDailyTotals.isEmpty { + bootstrapStreakIfNeeded(from: monthDailyTotals, todayDate: todayDate) + } + if todayTokens >= dailyStreakThreshold { + let prevLastDay = state.streak.lastDay + state.streak.recordActiveDay(todayDate) + if state.streak.lastDay != prevLastDay { + save() + } + } + } if !state.installBaselineSet { // 설치 기준선 — 실제 데이터가 도착한 시점의 today 를 baseline 으로(이전 사용량 미카운트). // 데이터 도착 전(기동 직후 빈 새로고침)에는 잡지 않는다. diff --git a/Sources/PokeTokenBar/Core/LocalUsageReader.swift b/Sources/PokeTokenBar/Core/LocalUsageReader.swift index 51011ce0..5d61b265 100644 --- a/Sources/PokeTokenBar/Core/LocalUsageReader.swift +++ b/Sources/PokeTokenBar/Core/LocalUsageReader.swift @@ -1772,6 +1772,18 @@ enum LocalUsageReader { return f } + /// 두 yyyy-MM-dd 날짜 사이의 달력 일수 차이 (to - from). 파싱 실패 시 nil. + static func dayDifference(from previous: String, to current: String, timeZone: TimeZone = .current) -> Int? { + if previous == current { return 0 } + let fmt = localDayFormatter(timeZone: timeZone) + guard let d1 = fmt.date(from: previous), let d2 = fmt.date(from: current) else { return nil } + var cal = Calendar(identifier: .gregorian) + cal.timeZone = timeZone + let start1 = cal.startOfDay(for: d1) + let start2 = cal.startOfDay(for: d2) + return cal.dateComponents([.day], from: start1, to: start2).day + } + /// 파싱 상한 — 실사용(수십억)의 10만 배라 정상 사용량을 자르지 않는다. /// `Int.max` 로 잡지 않는 이유: 클램프 자체는 되지만 `output + thoughts` 처럼 **파싱 직후 더하는** /// 지점에서 다시 오버플로 트랩이 난다. 이 값끼리 여러 번 더해도 Int64 안에 머무는 상한이어야 한다. diff --git a/Sources/PokeTokenBar/Core/Localization.swift b/Sources/PokeTokenBar/Core/Localization.swift index be0e78c3..0cb0e4a6 100644 --- a/Sources/PokeTokenBar/Core/Localization.swift +++ b/Sources/PokeTokenBar/Core/Localization.swift @@ -682,6 +682,37 @@ struct L { func toNextEvolution(_ amount: String) -> String { t("다음 진화까지 \(amount)", "\(amount) to next evolution", "次の進化まで \(amount)", "\(amount) para la siguiente evolución", "\(amount) avant la prochaine évolution", "\(amount) para a próxima evolución", "\(amount) bis zur nächsten Entwicklung") } func toGraduation(_ amount: String) -> String { t("졸업까지 \(amount)", "\(amount) to graduation", "卒業まで \(amount)", "\(amount) para graduarse", "\(amount) avant le diplôme", "\(amount) para se formar", "\(amount) bis zum Abschied") } func growthBoost(_ multiplier: Int) -> String { t("\(multiplier)× 성장", "\(multiplier)× growth", "成長 \(multiplier)倍", "Crecimiento ×\(multiplier)", "Croissance ×\(multiplier)", "Crescimento ×\(multiplier)", "\(multiplier)× Wachstum") } + private static func formatStreakMultiplier(_ multiplier: Double) -> String { + String(format: "%.2f", multiplier) + } + func streakBadge(days: Int, multiplier: Double) -> String { + let multStr = Self.formatStreakMultiplier(multiplier) + if multiplier > 1.0 { + return t("🔥 \(days)일 (\(multStr)×)", "🔥 \(days)d (\(multStr)×)", "🔥 \(days)日 (\(multStr)倍)", "🔥 \(days)d (\(multStr)×)", "🔥 \(days)j (\(multStr)×)", "🔥 \(days)d (\(multStr)×)", "🔥 \(days)T (\(multStr)×)") + } else { + return t("🔥 \(days)일", "🔥 \(days)d", "🔥 \(days)日", "🔥 \(days)d", "🔥 \(days)j", "🔥 \(days)d", "🔥 \(days)T") + } + } + func streakTooltip(days: Int, multiplier: Double) -> String { + let multStr = Self.formatStreakMultiplier(multiplier) + if multiplier > 1.0 { + return t("\(days)일 연속 코딩! 성장 속도 \(multStr)× 부스트 적용 중", + "\(days)-day coding streak! \(multStr)× growth boost active", + "\(days)日連続コーディング!成長速度\(multStr)倍ブースト適用中", + "¡Racha de \(days) días de código! Aumento de crecimiento de \(multStr)× activo", + "Série de \(days) jours de code ! Boost de croissance de \(multStr)× actif", + "Sequência de \(days) dias de código! Aumento de crescimento de \(multStr)× ativo", + "\(days) Tage in Folge programmiert! \(multStr)× Wachstums-Boost aktiv") + } else { + return t("\(days)일 연속 코딩 달성! 3일 연속 달성 시 1.10× 성장 부스트를 받습니다", + "\(days)-day coding streak! Reach 3 days to unlock 1.10× growth boost", + "\(days)日連続コーディング達成!3日連続達成で1.10倍の成長ブーストを獲得できます", + "¡Racha de \(days) días alcanzada! Llega a 3 días para desbloquear el aumento de 1.10×", + "Série de \(days) jours atteinte ! Atteignez 3 jours pour débloquer le boost de 1.10×", + "Sequência de \(days) dias atingida! Alcance 3 dias para desbloquear o aumento de 1.10×", + "\(days)-Tage-Serie erreicht! Erreiche 3 Tage für einen 1.10× Wachstums-Boost") + } + } func graduated(_ name: String) -> String { t("\(name) 졸업 → 도감에 보존. 새 Token Egg가 도착했어요!", "\(name) graduated → saved to the dex. A new Token Egg has arrived!", diff --git a/Sources/PokeTokenBar/Core/SaveTransfer.swift b/Sources/PokeTokenBar/Core/SaveTransfer.swift index df0bf394..c1b6ed6b 100644 --- a/Sources/PokeTokenBar/Core/SaveTransfer.swift +++ b/Sources/PokeTokenBar/Core/SaveTransfer.swift @@ -146,6 +146,7 @@ enum SaveTransfer { s.usedSinceInstall = clampToken(s.usedSinceInstall) s.spentTokens = clampToken(s.spentTokens) s.eggUsage = clampToken(s.eggUsage) + s.streak.days = min(max(0, s.streak.days), 3650) s.claimedTodayTokensByProvider = s.claimedTodayTokensByProvider?.reduce(into: [:]) { result, entry in result[entry.key] = clampToken(entry.value) } diff --git a/Sources/PokeTokenBar/PokeTokenBarApp.swift b/Sources/PokeTokenBar/PokeTokenBarApp.swift index 1bff0bbe..bd2d2c8c 100644 --- a/Sources/PokeTokenBar/PokeTokenBarApp.swift +++ b/Sources/PokeTokenBar/PokeTokenBarApp.swift @@ -231,7 +231,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { monthTotal: store.monthTotalTokens, burnTier: store.burnTier, limitWarning: store.isLimitWarning, - hasUsageData: store.hasUsageData) + hasUsageData: store.hasUsageData, + monthDailyTotals: companion.needsStreakBootstrap ? store.monthDailyTotals : []) } /// 매 refresh 완료 훅 — companion 갱신 + 사탕 지급(한도가 신선한 시점). 지급을 여기 묶는 이유는 diff --git a/Sources/PokeTokenBar/UI/CompanionView.swift b/Sources/PokeTokenBar/UI/CompanionView.swift index 30141696..7d548f58 100644 --- a/Sources/PokeTokenBar/UI/CompanionView.swift +++ b/Sources/PokeTokenBar/UI/CompanionView.swift @@ -564,7 +564,11 @@ struct CompanionHeader: View { // 단계 + 성격(부화 시 확정된 개체 아이덴티티) let nature = store.currentNature.map { " · \($0.name(store.language))" } ?? "" HStack(spacing: 5) { - Text(store.stageText + nature).font(.caption2).foregroundStyle(.secondary) + Text(store.stageText + nature) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) if let multiplier = store.growthMultiplier { Text(store.l.growthBoost(multiplier)) .font(.system(size: 8, weight: .bold)) @@ -573,6 +577,7 @@ struct CompanionHeader: View { .clipShape(Capsule()) .fixedSize() } + streakBadge } ProgressView(value: store.progress).controlSize(.small).tint(.orange) if store.tokensToNext > 0 { @@ -594,6 +599,7 @@ struct CompanionHeader: View { .background(rarityColor(guarantee)).foregroundStyle(.white) .clipShape(Capsule()) } + streakBadge } ProgressView(value: store.eggProgress).controlSize(.small).tint(.orange) if store.isEgg, store.isHatchRetryDelayed { @@ -639,6 +645,19 @@ struct CompanionHeader: View { .onChange(of: eggImminent) { syncEggWiggle() } } + @ViewBuilder + private var streakBadge: some View { + if store.streakDays > 0 { + Text(store.streakBadgeText) + .font(.system(size: 8, weight: .bold)) + .padding(.horizontal, 5).padding(.vertical, 1) + .background(.red.opacity(0.12)).foregroundStyle(.red) + .clipShape(Capsule()) + .fixedSize() + .help(store.streakTooltipText) + } + } + /// 부화/진화 연출 1회 재생 — 흰 플래시 페이드아웃 + 스프링 팝. shiny 부화는 ✨ 버스트 추가. private func playCelebrationIfNeeded() { guard let c = store.celebration, store.celebrationSeq != seenSeq else { return } diff --git a/Tests/PokeTokenBarTests/LocalizationInterpolationTests.swift b/Tests/PokeTokenBarTests/LocalizationInterpolationTests.swift index 86d87ff0..1fcba5b2 100644 --- a/Tests/PokeTokenBarTests/LocalizationInterpolationTests.swift +++ b/Tests/PokeTokenBarTests/LocalizationInterpolationTests.swift @@ -80,6 +80,10 @@ final class LocalizationInterpolationTests: XCTestCase { expect(lang, "toNextEvolution", l.toNextEvolution(a), a) expect(lang, "toGraduation", l.toGraduation(a), a) expect(lang, "growthBoost", l.growthBoost(4242), "4242") + expect(lang, "streakBadge", l.streakBadge(days: 4242, multiplier: 1.25), "4242", "1.25") + expect(lang, "streakBadgeActive", l.streakBadge(days: 4242, multiplier: 1.0), "4242") + expect(lang, "streakTooltip", l.streakTooltip(days: 4242, multiplier: 1.25), "4242", "1.25") + expect(lang, "streakTooltipInactive", l.streakTooltip(days: 4242, multiplier: 1.0), "4242") expect(lang, "graduated", l.graduated(a), a) expect(lang, "statusEvolved", l.statusEvolved(a), a) diff --git a/Tests/PokeTokenBarTests/SaveTransferTests.swift b/Tests/PokeTokenBarTests/SaveTransferTests.swift index 63a3e5ff..5bde062b 100644 --- a/Tests/PokeTokenBarTests/SaveTransferTests.swift +++ b/Tests/PokeTokenBarTests/SaveTransferTests.swift @@ -127,6 +127,18 @@ final class SaveTransferTests: XCTestCase { XCTAssertEqual(envelope.state.active?.phaseThreshold, 62_500_000) } + func testRoundTripPreservesDailyStreak() throws { + var original = CompanionState() + original.streak = StreakState(days: 7, lastDay: "2026-09-23") + + let data = try SaveTransfer.encode(state: original, appVersion: "2.5.0", + deviceName: "Old Mac", now: transferNow) + let envelope = try SaveTransfer.decode(data) + + XCTAssertEqual(envelope.state.streak.days, 7) + XCTAssertEqual(envelope.state.streak.lastDay, "2026-09-23") + } + /// [핵심] 봉투가 없으면 `CompanionState` 의 관대 디코딩이 아무 JSON 이나 빈 상태로 흡수해 /// "불러오기 성공 → 도감이 사라짐"이 된다. 포맷 id 로 먼저 거른다. func testForeignJSONIsRejectedRatherThanImportedAsEmptyState() throws { @@ -528,7 +540,7 @@ final class SaveTransferTests: XCTestCase { func testEveryCompanionStateFieldIsClassifiedForTransfer() { // eggTier(알 등급 보증) = 진행 — 산 물건이지 이 기기의 장부가 아니라 기기를 옮겨도 따라간다. let progress: Set = ["usedSinceInstall", "spentTokens", "eggUsage", "eggTier", - "pendingHatchID", "pendingUnownForm", "active", + "pendingHatchID", "pendingUnownForm", "streak", "active", "representativeSpeciesID", "representativeUnownForm", "dex", "collectedFinals", "inventory"] let deviceLedger: Set = ["installBaselineSet", "claimedTodayTokensByProvider", "lastDate"] diff --git a/Tests/PokeTokenBarTests/StreakMultiplierTests.swift b/Tests/PokeTokenBarTests/StreakMultiplierTests.swift new file mode 100644 index 00000000..9087e975 --- /dev/null +++ b/Tests/PokeTokenBarTests/StreakMultiplierTests.swift @@ -0,0 +1,349 @@ +import XCTest +@testable import PokeTokenBar + +@MainActor +final class StreakMultiplierTests: XCTestCase { + private static func defaultLine() -> EvoLine { + var names: [Int: [String: String]] = [:] + for id in [1, 2, 3] { names[id] = ["en": "P\(id)", "ko": "포켓몬\(id)"] } + return EvoLine(baseID: 1, + tree: EvoNode(speciesID: 1, children: [EvoNode(speciesID: 2, children: [EvoNode(speciesID: 3, children: [])])]), + rarity: .common, names: names) + } + + @MainActor + private func makeStore(state: CompanionState = CompanionState(), + growthDifficulty: Double = 1.0, + line: EvoLine? = nil) -> CompanionStore { + let file = FileManager.default.temporaryDirectory.appendingPathComponent("ptb-streak-\(UUID().uuidString).json") + let data = try! JSONEncoder().encode(state) + try! data.write(to: file) + let defs = UserDefaults(suiteName: "test-\(UUID().uuidString)")! + defs.set(growthDifficulty, forKey: "growthDifficulty") + let provider = StubDiffProvider(value: line ?? Self.defaultLine()) + return CompanionStore(provider: provider, + clock: { Date(timeIntervalSince1970: 1_700_000_000) }, + fileURL: file, rng: SeededDiffRNG(seed: 7), + dittoDisguiseRollingEnabled: false, + defaults: defs) + } + + // MARK: - Multiplier Tiers + + func testStreakMultiplierTiers() { + XCTAssertEqual(PokemonBalance.streakMultiplier(for: 0), 1.0) + XCTAssertEqual(PokemonBalance.streakMultiplier(for: 1), 1.0) + XCTAssertEqual(PokemonBalance.streakMultiplier(for: 2), 1.0) + XCTAssertEqual(PokemonBalance.streakMultiplier(for: 3), 1.10) + XCTAssertEqual(PokemonBalance.streakMultiplier(for: 6), 1.10) + XCTAssertEqual(PokemonBalance.streakMultiplier(for: 7), 1.25) + XCTAssertEqual(PokemonBalance.streakMultiplier(for: 13), 1.25) + XCTAssertEqual(PokemonBalance.streakMultiplier(for: 14), 1.35) + XCTAssertEqual(PokemonBalance.streakMultiplier(for: 29), 1.35) + XCTAssertEqual(PokemonBalance.streakMultiplier(for: 30), 1.50) + XCTAssertEqual(PokemonBalance.streakMultiplier(for: 100), 1.50) + } + + // MARK: - Day Difference + + func testDayDifferenceCalculation() { + XCTAssertEqual(LocalUsageReader.dayDifference(from: "2026-09-22", to: "2026-09-22"), 0) + XCTAssertEqual(LocalUsageReader.dayDifference(from: "2026-09-22", to: "2026-09-23"), 1) + XCTAssertEqual(LocalUsageReader.dayDifference(from: "2026-09-21", to: "2026-09-23"), 2) + XCTAssertEqual(LocalUsageReader.dayDifference(from: "2026-09-23", to: "2026-09-22"), -1) + // Month boundary + XCTAssertEqual(LocalUsageReader.dayDifference(from: "2026-09-30", to: "2026-10-01"), 1) + // Year boundary + XCTAssertEqual(LocalUsageReader.dayDifference(from: "2026-12-31", to: "2027-01-01"), 1) + // Leap year + XCTAssertEqual(LocalUsageReader.dayDifference(from: "2028-02-28", to: "2028-02-29"), 1) + XCTAssertEqual(LocalUsageReader.dayDifference(from: "2028-02-29", to: "2028-03-01"), 1) + // Invalid date + XCTAssertNil(LocalUsageReader.dayDifference(from: "invalid", to: "2026-09-23")) + } + + // MARK: - State Transitions & Morning Tolerance + + func testStreakStateTransitionsAndMorningTolerance() { + var streak = StreakState() + XCTAssertEqual(streak.effectiveDays(todayDate: "2026-09-21"), 0) + + // Day 1: User reaches 10M tokens on 2026-09-21 + streak.recordActiveDay("2026-09-21") + XCTAssertEqual(streak.days, 1) + XCTAssertEqual(streak.lastDay, "2026-09-21") + XCTAssertEqual(streak.effectiveDays(todayDate: "2026-09-21"), 1) + + // Multiple recordings on the same day do not increase streak + streak.recordActiveDay("2026-09-21") + XCTAssertEqual(streak.days, 1) + + // Day 2 morning (2026-09-22): User has not recorded tokens yet today + // Morning tolerance: yesterday was active, so streak of 1 day is still effective today! + XCTAssertEqual(streak.effectiveDays(todayDate: "2026-09-22"), 1) + + // Day 2 afternoon: User reaches 10M tokens today + streak.recordActiveDay("2026-09-22") + XCTAssertEqual(streak.days, 2) + XCTAssertEqual(streak.lastDay, "2026-09-22") + XCTAssertEqual(streak.effectiveDays(todayDate: "2026-09-22"), 2) + + // Day 3: Consecutive day recorded -> reaches 3 days (1.10x boost unlocked) + streak.recordActiveDay("2026-09-23") + XCTAssertEqual(streak.days, 3) + XCTAssertEqual(streak.lastDay, "2026-09-23") + XCTAssertEqual(streak.effectiveDays(todayDate: "2026-09-23"), 3) + + // Missed day: User misses 2026-09-24. On 2026-09-25 morning: + // Difference from 2026-09-23 to 2026-09-25 is 2 (> 1) -> streak is broken! + XCTAssertEqual(streak.effectiveDays(todayDate: "2026-09-25"), 0) + + // User reaches 10M tokens on 2026-09-25 -> resets to day 1 + streak.recordActiveDay("2026-09-25") + XCTAssertEqual(streak.days, 1) + XCTAssertEqual(streak.lastDay, "2026-09-25") + XCTAssertEqual(streak.effectiveDays(todayDate: "2026-09-25"), 1) + } + + // MARK: - Multiplier Scales Thresholds + + func testMultiplierReducesEggHatchAndStageThresholds() { + let today = LocalUsageReader.todayKey() + var state = CompanionState() + state.streak = StreakState(days: 7, lastDay: today) + state.active = MonState(baseID: 1, pathIDs: [1, 2, 3], stageIndex: 0, + usedAtStage: 0, rarity: .common, totalForms: 3) + let store = makeStore(state: state) + + // 7-day streak has 1.25x multiplier + XCTAssertEqual(store.streakDays, 7) + XCTAssertEqual(store.streakMultiplier, 1.25) + + // Base common stage 0 threshold is 125M tokens. + // With 1.25x boost: 125_000_000 / 1.25 = 100_000_000 + XCTAssertEqual(store.threshold, 100_000_000) + + // Base egg hatch threshold is 5M tokens. + // With 1.25x boost: 5_000_000 / 1.25 = 4_000_000 + var eggState = CompanionState() + eggState.streak = StreakState(days: 7, lastDay: today) + eggState.active = nil + let eggStore = makeStore(state: eggState) + XCTAssertEqual(eggStore.eggTokensToHatch, 4_000_000) + } + + // MARK: - Multiplicative Composition with Repeat Boost and Difficulty + + func testMultiplierComposesWithRepeatBoostAndDifficulty() { + let today = LocalUsageReader.todayKey() + var state = CompanionState() + state.streak = StreakState(days: 7, lastDay: today) + // Mon with repeat boost (hasGrowthBoost = true -> 2x) + state.active = MonState(baseID: 1, pathIDs: [1, 2, 3], stageIndex: 0, + usedAtStage: 0, rarity: .common, totalForms: 3, + hasGrowthBoost: true) + + // Base phaseThreshold with repeat boost (2x) is 62_500_000 + // Combined with 7-day streak (1.25x): 62_500_000 / 1.25 = 50_000_000 (total 2.5x growth!) + let store = makeStore(state: state, growthDifficulty: 1.0) + XCTAssertEqual(store.threshold, 50_000_000) + + // If growth difficulty is 1.5: 50_000_000 * 1.5 = 75_000_000 + store.setGrowthDifficulty(1.5) + XCTAssertEqual(store.threshold, 75_000_000) + } + + // MARK: - Streak Accelerates Evolution + + func testStreakAcceleratesEvolution() async { + let store = makeStore() + await store.hatch(baseID: 1) + + let fmt = LocalUsageReader.localDayFormatter() + let today = Date() + let cal = Calendar(identifier: .gregorian) + var series: [DailyUsage] = [] + for i in (0..<7).reversed() { + let d = cal.date(byAdding: .day, value: -i, to: today)! + let dateStr = fmt.string(from: d) + series.append(DailyUsage(date: dateStr, inputTokens: 0, outputTokens: 0, + cacheCreationTokens: 0, cacheReadTokens: 0, + totalTokens: 10_000_000, totalCost: 0, costCoverage: .empty)) + } + let todayStr = fmt.string(from: today) + store.bootstrapStreakIfNeeded(from: series, todayDate: todayStr) + + XCTAssertEqual(store.streakDays, 7) + XCTAssertEqual(store.streakMultiplier, 1.25) + XCTAssertEqual(store.threshold, 100_000_000) + XCTAssertEqual(store.state.active?.stageIndex, 0) + + // Apply 99M tokens -> still in stage 0 + store.applyUsage(99_000_000) + XCTAssertEqual(store.state.active?.stageIndex, 0) + XCTAssertEqual(store.state.active?.usedAtStage, 99_000_000) + + // Apply 2M delta -> 99M + 2M = 101M >= 100M threshold -> triggers evolution to stage 1! + store.applyUsage(2_000_000) + + XCTAssertEqual(store.state.active?.stageIndex, 1) + XCTAssertEqual(store.state.active?.currentID, 2) + // Excess 1M carries over to stage 1 + XCTAssertEqual(store.state.active?.usedAtStage, 1_000_000) + } + + // MARK: - Bootstrap From Month Daily Totals + + func testBootstrapStreakFromMonthDailyTotals() { + let series = [ + DailyUsage(date: "2026-09-20", inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, + cacheReadTokens: 0, totalTokens: 15_000_000, totalCost: 0, costCoverage: .empty), + DailyUsage(date: "2026-09-21", inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, + cacheReadTokens: 0, totalTokens: 20_000_000, totalCost: 0, costCoverage: .empty), + DailyUsage(date: "2026-09-22", inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, + cacheReadTokens: 0, totalTokens: 12_000_000, totalCost: 0, costCoverage: .empty), + DailyUsage(date: "2026-09-23", inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, + cacheReadTokens: 0, totalTokens: 1_000_000, totalCost: 0, costCoverage: .empty) + ] + + let store = makeStore() + XCTAssertEqual(store.state.streak.days, 0) + + // Bootstrap on 2026-09-23 (where today is 1M < 10M, but yesterday 2026-09-22 completed 3 days) + store.bootstrapStreakIfNeeded(from: series, todayDate: "2026-09-23") + + XCTAssertEqual(store.state.streak.days, 3) + XCTAssertEqual(store.state.streak.lastDay, "2026-09-22") + // Check effective days on 2026-09-23 + XCTAssertEqual(store.state.streak.effectiveDays(todayDate: "2026-09-23"), 3) + } + + // MARK: - Save Sanitization + + func testSaveSanitizationClampsStreakDays() { + var state = CompanionState() + state.streak.days = 99999 + let sanitized = SaveTransfer.sanitized(state) + XCTAssertEqual(sanitized.streak.days, 3650) + + var negative = CompanionState() + negative.streak.days = -10 + let sanitizedNegative = SaveTransfer.sanitized(negative) + XCTAssertEqual(sanitizedNegative.streak.days, 0) + } + + // MARK: - Extreme Difficulty Scaling (Defect Log Requirement) + + func testMultiplierAtExtremeDifficulties() { + let today = LocalUsageReader.todayKey() + var state = CompanionState() + state.streak = StreakState(days: 7, lastDay: today) // 1.25x + state.active = MonState(baseID: 1, pathIDs: [1, 2, 3], stageIndex: 0, + usedAtStage: 0, rarity: .common, totalForms: 3) + + // At lowest difficulty (0.1): base 125M * 0.1 = 12.5M. + // With 1.25x streak: 12.5M / 1.25 = 10M. + // Verifies difficulty clamping does not swallow streak multiplier. + let lowStore = makeStore(state: state, growthDifficulty: 0.1) + XCTAssertEqual(lowStore.threshold, 10_000_000) + + // At highest difficulty (2.0): base 125M * 2.0 = 250M. + // With 1.25x streak: 250M / 1.25 = 200M. + let highStore = makeStore(state: state, growthDifficulty: 2.0) + XCTAssertEqual(highStore.threshold, 200_000_000) + } + + // MARK: - Timezone Travel + + func testTimezoneTravelPreservesStreak() { + var streak = StreakState(days: 3, lastDay: "2026-09-23") + + // User travels west to a timezone where local date is still yesterday (2026-09-22) + XCTAssertEqual(streak.effectiveDays(todayDate: "2026-09-22"), 3) + + // Coding in that earlier timezone must not reset or regress streak + streak.recordActiveDay("2026-09-22") + XCTAssertEqual(streak.days, 3) + XCTAssertEqual(streak.lastDay, "2026-09-23") + + // The following day (2026-09-24), streak is preserved and advances + XCTAssertEqual(streak.effectiveDays(todayDate: "2026-09-24"), 3) + streak.recordActiveDay("2026-09-24") + XCTAssertEqual(streak.days, 4) + XCTAssertEqual(streak.lastDay, "2026-09-24") + } + + // MARK: - Multi-Provider Usage Accumulation in Update + + func testMultiProviderUsageReachesStreakThresholdInUpdate() { + let store = makeStore() + XCTAssertEqual(store.state.streak.days, 0) + + // Multi-provider tokens: Claude 6M + Codex 4.5M = 10.5M >= 10M threshold + store.update( + todayTokensByProvider: ["claude": 6_000_000, "codex": 4_500_000], + todayDate: "2026-09-23", + monthTotal: 10_500_000, + burnTier: .normal, + limitWarning: false, + hasUsageData: true + ) + + XCTAssertEqual(store.state.streak.days, 1) + XCTAssertEqual(store.state.streak.lastDay, "2026-09-23") + XCTAssertEqual(store.streakDays, 1) + } + + // MARK: - Daily Streak Threshold Setting & Clamping + + func testDailyStreakThresholdSettingAndClamping() { + let store = makeStore() + XCTAssertEqual(store.dailyStreakThreshold, 10_000_000) + + store.setDailyStreakThreshold(5_000_000) + XCTAssertEqual(store.dailyStreakThreshold, 5_000_000) + + store.setDailyStreakThreshold(-50) + XCTAssertEqual(store.dailyStreakThreshold, 1) + + store.setDailyStreakThreshold(2_000_000_000) + XCTAssertEqual(store.dailyStreakThreshold, 1_000_000_000) + } + + // MARK: - Multiplier Formatting + + func testMultiplierFormattingInLocalization() { + let l = L(.en) + let badge3 = l.streakBadge(days: 3, multiplier: 1.10) + XCTAssertEqual(badge3, "🔥 3d (1.10×)") + + let badge7 = l.streakBadge(days: 7, multiplier: 1.25) + XCTAssertEqual(badge7, "🔥 7d (1.25×)") + + let badge30 = l.streakBadge(days: 30, multiplier: 1.50) + XCTAssertEqual(badge30, "🔥 30d (1.50×)") + + let tooltip3 = l.streakTooltip(days: 3, multiplier: 1.10) + XCTAssertTrue(tooltip3.contains("1.10× growth boost active")) + } +} + +// MARK: - Test Stubs + +private struct StubDiffProvider: PokeProviding { + let value: EvoLine + func line(baseSpeciesID: Int) async throws -> EvoLine { value } + func baseSpeciesIndex() async throws -> [BaseSpecies] { [BaseSpecies(id: value.baseID, captureRate: 255)] } +} + +private struct SeededDiffRNG: RandomNumberGenerator { + var state: UInt64 + init(seed: UInt64) { state = seed } + mutating func next() -> UInt64 { + state = state &+ 0x9E37_79B9_7F4A_7C15 + var z = state + z = (z ^ (z >> 30)) &* 0xBF58_476D_1CE4_E5B9 + z = (z ^ (z >> 27)) &* 0x94D0_49BB_1331_11EB + return z ^ (z >> 31) + } +}