Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions Sources/PokeTokenBar/Core/CompanionModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
// 토큰: 설치 이후만 측정
Expand Down Expand Up @@ -637,6 +680,8 @@ struct CompanionState: Codable, Sendable {
/// 키는 `UsageProvider.id`를 그대로 사용한다.
var claimedTodayTokensByProvider: [String: Int]? = nil
var lastDate = ""
// 연속 코딩 스트릭 (일수 + 마지막 달성일)
var streak = StreakState()
// 현재 포켓몬(없으면 알)
var active: MonState?
// 메뉴바와 플로팅 펫에 고정한 대표 종. nil = 현재 키우는 포켓몬(또는 알)을 그대로 따라간다.
Expand Down Expand Up @@ -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)
Expand Down
101 changes: 96 additions & 5 deletions Sources/PokeTokenBar/Core/CompanionStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 선택은 기존 동작(현재 개체/알)을 보존한다.
/// 저장된 값이라 상시 렌더링 경로가 도감 전체를 다시 접거나 불필요한 상태를 관찰하지 않는다.
Expand Down Expand Up @@ -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 으로(이전 사용량 미카운트).
// 데이터 도착 전(기동 직후 빈 새로고침)에는 잡지 않는다.
Expand Down
12 changes: 12 additions & 0 deletions Sources/PokeTokenBar/Core/LocalUsageReader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 안에 머무는 상한이어야 한다.
Expand Down
31 changes: 31 additions & 0 deletions Sources/PokeTokenBar/Core/Localization.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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!",
Expand Down
1 change: 1 addition & 0 deletions Sources/PokeTokenBar/Core/SaveTransfer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
3 changes: 2 additions & 1 deletion Sources/PokeTokenBar/PokeTokenBarApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 갱신 + 사탕 지급(한도가 신선한 시점). 지급을 여기 묶는 이유는
Expand Down
21 changes: 20 additions & 1 deletion Sources/PokeTokenBar/UI/CompanionView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -573,6 +577,7 @@ struct CompanionHeader: View {
.clipShape(Capsule())
.fixedSize()
}
streakBadge
}
ProgressView(value: store.progress).controlSize(.small).tint(.orange)
if store.tokensToNext > 0 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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 }
Expand Down
4 changes: 4 additions & 0 deletions Tests/PokeTokenBarTests/LocalizationInterpolationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Loading
Loading