Skip to content
Merged
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
15 changes: 14 additions & 1 deletion Sources/PokeTokenBar/Core/CompanionModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -288,10 +288,20 @@ enum WindowClass: Sendable { case session, weekly }

/// 사탕 지급 판정 입력 — 프로바이더 무관 한도 창 1개. (UsageStore.candyEligibleWindows 가 생성)
struct CandyWindow: Sendable {
let key: String // 안정 식별자(tier 추적) — resets_at 등 휘발 필드 금지
let key: String // 안정 식별자(tier 추적) — resets_at 등 휘발 필드는 key 에 넣지 않는다
let name: String // 표시용(알림 "왜 받는지")
let kind: WindowClass // session=1개 · weekly=5개
let utilization: Double // 0~100+
/// 창 epoch (`resets_at` / reset unix). key 는 안정, epoch 가 바뀌면 새 창 → 재무장(#326).
let epoch: String?

init(key: String, name: String, kind: WindowClass, utilization: Double, epoch: String? = nil) {
self.key = key
self.name = name
self.kind = kind
self.utilization = utilization
self.epoch = epoch
}
}

/// 사탕 지급 1건(순수 판정 결과) — 부수효과(인벤토리·알림)와 분리해 테스트 가능하게.
Expand Down Expand Up @@ -653,6 +663,8 @@ struct CompanionState: Codable, Sendable {
var inventory: [String: Int] = [:]
// 사탕 지급 엣지 상태(창 key → 지급한 tier). ★영속 — notifiedTier(인메모리)와 달리 재시작 무한지급 방지.
var candyGrantTier: [String: Int] = [:]
// 창 key → 마지막으로 본 epoch(`resets_at`). util 이 계속 100%여도 epoch 교체로 재무장(#326).
var candyWindowEpoch: [String: String] = [:]
// 사탕 지급 첫 실행 시드 완료 — 업데이트 직후 이미 100%였던 창의 소급 지급 차단.
var candyFeatureSeeded = false

Expand Down Expand Up @@ -693,6 +705,7 @@ struct CompanionState: Codable, Sendable {
language = c.lenient(AppLanguage.self, forKey: .language, default: .systemDefault)
inventory = c.lenient([String: Int].self, forKey: .inventory, default: [:])
candyGrantTier = c.lenient([String: Int].self, forKey: .candyGrantTier, default: [:])
candyWindowEpoch = c.lenient([String: String].self, forKey: .candyWindowEpoch, default: [:])
candyFeatureSeeded = c.lenient(Bool.self, forKey: .candyFeatureSeeded, default: false)
}

Expand Down
40 changes: 33 additions & 7 deletions Sources/PokeTokenBar/Core/CompanionStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1294,14 +1294,25 @@ final class CompanionStore {
func buyFreshEgg() -> Bool { buyEgg(nil) }

/// 지급 판정(순수·엣지 트리거) — 한도 창이 100% 를 새로 넘어선 순간에만 지급.
/// - 100% 미만 → 맵에서 제거(재무장). resets_at 등 휘발 필드는 key 에 없다(안정 식별자만).
/// - 100% 미만 → 맵에서 제거(재무장).
/// - 같은 key 라도 epoch(`resets_at`)가 바뀌면 새 창으로 보고 재무장 — 슬립·종료로 <100% 샘플을
/// 못 본 채 다시 100%만 관측해도 지급 누락이 없다(#326). key 자체엔 epoch 를 넣지 않는다
/// (알림 dedup 과 같은 이유로 매 fetch 새 키 폭탄 방지).
/// - 이미 지급한 창(tier≥1)은 재지급 안 함. session=1개·weekly=weeklyGrant.
/// - 부수효과(인벤토리·알림)와 분리해 xctest 가능. (evaluateLimitAlerts 자매)
static func evaluateCandyGrants(
windows: [CandyWindow], grantTier: inout [String: Int]
windows: [CandyWindow],
grantTier: inout [String: Int],
windowEpoch: inout [String: String]
) -> [CandyGrant] {
var grants: [CandyGrant] = []
for w in windows {
if let epoch = w.epoch {
if let previousEpoch = windowEpoch[w.key], previousEpoch != epoch {
grantTier[w.key] = nil
}
windowEpoch[w.key] = epoch
}
guard w.utilization >= 100 else { grantTier[w.key] = nil; continue }
let previous = grantTier[w.key] ?? 0
guard previous < 1 else { continue }
Expand All @@ -1322,22 +1333,37 @@ final class CompanionStore {
// 이후 다른 프로바이더가 이미 100%인 채 로드되면 소급 지급될 수 있으나, 1회·소수 캔디라
// 1인 로컬에서 무시(YAGNI). refresh() 는 전 프로바이더 fetch 를 await 후 onRefresh 하므로
// 정상 경로(둘 다 성공)에선 원자적 시드다.
for w in windows where w.utilization >= 100 { state.candyGrantTier[w.key] = 1 }
for w in windows where w.utilization >= 100 {
state.candyGrantTier[w.key] = 1
if let epoch = w.epoch { state.candyWindowEpoch[w.key] = epoch }
}
state.candyFeatureSeeded = true
save()
return
}
let before = state.candyGrantTier
let grants = Self.evaluateCandyGrants(windows: windows, grantTier: &state.candyGrantTier)
let beforeTier = state.candyGrantTier
let beforeEpoch = state.candyWindowEpoch
var grantTier = state.candyGrantTier
var windowEpoch = state.candyWindowEpoch
let grants = Self.evaluateCandyGrants(
windows: windows,
grantTier: &grantTier,
windowEpoch: &windowEpoch)
state.candyGrantTier = grantTier
state.candyWindowEpoch = windowEpoch
for g in grants {
state.inventory[ItemKind.rareCandy.rawValue, default: 0] += g.count
// 지급 자체는 알림 여부와 무관(상태 변경). 알림은 "왜 받는지"(그 창 한도를 다 채운 수고) 명시.
notifyCompanionEvent(l.notifCandyTitle(item: l.itemName(.rareCandy), count: g.count),
l.notifCandyBody(window: g.windowName))
}
// 지급이 없어도 재무장(창이 100%→아래로 내려가며 grantTier 에서 제거)은 영속해야 한다 —
// 지급이 없어도 재무장(util dip / epoch 교체)은 영속해야 한다 —
// 안 하면 재시작 시 stale tier=1 로 다음 100% 도달이 "이미 지급"으로 오판돼 지급 누락(회귀).
if !grants.isEmpty || state.candyGrantTier != before { save() }
if !grants.isEmpty
|| state.candyGrantTier != beforeTier
|| state.candyWindowEpoch != beforeEpoch {
save()
}
}

/// companion 이벤트 시스템 알림(.app + 토글 ON 일 때만). 한도 알림과 독립.
Expand Down
29 changes: 28 additions & 1 deletion Sources/PokeTokenBar/Core/SaveTransfer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ enum SaveTransfer {
/// - **기기 환경설정**: 진행이 아니라 이 기기에서 보는 방식(`language`) → **현재 기기 값을 지킨다**.
/// 일본어 Mac 의 세이브가 영어 Mac 의 UI 언어를 바꾸면 안 된다.
///
/// 계정 전역 원장(`candyGrantTier`)은 교체가 아니라 **key 별 max 병합**이다. 한도 창 key 는 계정
/// 계정 전역 원장(`candyGrantTier`·`candyWindowEpoch`)은 교체가 아니라 **key 별 병합**이다. 한도 창 key 는 계정
/// 단위라 두 기기가 같은 창을 본다 — 더 오래된 세이브로 통째 교체하면 이미 지급한 창의 기록이
/// 사라져 같은 창에서 사탕이 재지급된다(보존만으로는 이 역방향을 못 막는다).
static func rebasedForThisDevice(_ imported: CompanionState,
Expand All @@ -198,6 +198,9 @@ enum SaveTransfer {
var state = imported
state.language = current.language
state.candyGrantTier = mergedGrantTier(imported.candyGrantTier, current.candyGrantTier)
state.candyWindowEpoch = mergedWindowEpochs(
imported.candyWindowEpoch, imported.candyGrantTier,
current.candyWindowEpoch, current.candyGrantTier)
state.candyFeatureSeeded = imported.candyFeatureSeeded || current.candyFeatureSeeded
let hasCurrentProviderData = hasUsageData && !todayTokensByProvider.isEmpty
if hasCurrentProviderData {
Expand All @@ -221,4 +224,28 @@ enum SaveTransfer {
static func mergedGrantTier(_ a: [String: Int], _ b: [String: Int]) -> [String: Int] {
a.merging(b) { max($0, $1) }
}

/// epoch 병합 — 양쪽이 이미 지급(tier≥1)한 창은 더 늦은 epoch 를 남겨 현재 창에서 재지급을 막고,
/// 한쪽만 지급했으면 그쪽 epoch 를 쓴다(다른 기기가 못 본 창은 이후 live epoch 교체로 재무장 가능).
static func mergedWindowEpochs(
_ epochsA: [String: String], _ tiersA: [String: Int],
_ epochsB: [String: String], _ tiersB: [String: Int]
) -> [String: String] {
let keys = Set(epochsA.keys).union(epochsB.keys).union(tiersA.keys).union(tiersB.keys)
var out: [String: String] = [:]
for key in keys {
let tierA = tiersA[key] ?? 0
let tierB = tiersB[key] ?? 0
if tierA > 0, tierB > 0 {
out[key] = [epochsA[key], epochsB[key]].compactMap { $0 }.max()
} else if tierA > 0 {
out[key] = epochsA[key]
} else if tierB > 0 {
out[key] = epochsB[key]
} else {
out[key] = [epochsA[key], epochsB[key]].compactMap { $0 }.max()
}
}
return out.compactMapValues { $0 }
}
}
22 changes: 14 additions & 8 deletions Sources/PokeTokenBar/Core/UsageStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -618,14 +618,16 @@ final class UsageStore {
key: "codex.\(bucketKey).primary",
name: "\(bucketName) \(l.codexWindow(primary.windowDurationMins))",
kind: Self.windowClass(minutes: primary.windowDurationMins),
utilization: Double(primary.usedPercent)))
utilization: Double(primary.usedPercent),
epoch: primary.resetsAt.map(String.init)))
}
if let secondary = bucket.secondary {
windows.append(CandyWindow(
key: "codex.\(bucketKey).secondary",
name: "\(bucketName) \(l.codexWindow(secondary.windowDurationMins))",
kind: Self.windowClass(minutes: secondary.windowDurationMins),
utilization: Double(secondary.usedPercent)))
utilization: Double(secondary.usedPercent),
epoch: secondary.resetsAt.map(String.init)))
}
}
for group in antigravityLimits?.groups ?? [] {
Expand All @@ -636,14 +638,16 @@ final class UsageStore {
key: "antigravity.\(groupKey).5h",
name: "\(groupTitle) \(l.fiveHourSession)",
kind: .session,
utilization: fiveHour.usedPercent))
utilization: fiveHour.usedPercent,
epoch: fiveHour.resetTime))
}
if let weekly = group.weeklyBucket {
windows.append(CandyWindow(
key: "antigravity.\(groupKey).weekly",
name: "\(groupTitle) \(l.weekly)",
kind: .weekly,
utilization: weekly.usedPercent))
utilization: weekly.usedPercent,
epoch: weekly.resetTime))
}
}
return windows
Expand All @@ -653,13 +657,15 @@ final class UsageStore {
private func claudeCandyWindows(_ account: ClaudeAccountLimits, named: Bool, _ l: L) -> [CandyWindow] {
let suffix = named ? " (\(account.title))" : ""
var windows: [CandyWindow] = []
if let u = account.status.fiveHour?.utilization {
if let fiveHour = account.status.fiveHour, let u = fiveHour.utilization {
windows.append(CandyWindow(key: "\(account.windowKeyPrefix).fiveHour",
name: l.claudeFiveHour + suffix, kind: .session, utilization: u))
name: l.claudeFiveHour + suffix, kind: .session,
utilization: u, epoch: fiveHour.resetsAt))
}
if let u = account.status.sevenDay?.utilization {
if let sevenDay = account.status.sevenDay, let u = sevenDay.utilization {
windows.append(CandyWindow(key: "\(account.windowKeyPrefix).sevenDay",
name: l.claudeWeekly + suffix, kind: .weekly, utilization: u))
name: l.claudeWeekly + suffix, kind: .weekly,
utilization: u, epoch: sevenDay.resetsAt))
}
return windows
}
Expand Down
16 changes: 13 additions & 3 deletions Tests/PokeTokenBarTests/AdditionalClaudeAccountsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -846,13 +846,23 @@ final class AdditionalClaudeAccountsStoreTests: XCTestCase {
await store.refresh(scheduleEmptyRetry: false)
await store.refresh(scheduleEmptyRetry: false)
var tiers: [String: Int] = [:]
var epochs: [String: String] = [:]

let grants = CompanionStore.evaluateCandyGrants(windows: store.candyEligibleWindows, grantTier: &tiers)
let grants = CompanionStore.evaluateCandyGrants(
windows: store.candyEligibleWindows,
grantTier: &tiers,
windowEpoch: &epochs)
XCTAssertEqual(grants.map(\.count).reduce(0, +), 1 + 1 + RareCandy.weeklyGrant)

let defaultOnly = store.candyEligibleWindows.filter { !$0.key.contains(workKey) }
XCTAssertTrue(CompanionStore.evaluateCandyGrants(windows: defaultOnly, grantTier: &tiers).isEmpty)
XCTAssertTrue(CompanionStore.evaluateCandyGrants(windows: store.candyEligibleWindows, grantTier: &tiers).isEmpty)
XCTAssertTrue(CompanionStore.evaluateCandyGrants(
windows: defaultOnly,
grantTier: &tiers,
windowEpoch: &epochs).isEmpty)
XCTAssertTrue(CompanionStore.evaluateCandyGrants(
windows: store.candyEligibleWindows,
grantTier: &tiers,
windowEpoch: &epochs).isEmpty)
}

/// The first-run seed only covers the windows known at first launch: an account found later
Expand Down
Loading
Loading