diff --git a/Sources/PokeTokenBar/Core/CompanionModel.swift b/Sources/PokeTokenBar/Core/CompanionModel.swift index 4977d8b2..a892e51f 100644 --- a/Sources/PokeTokenBar/Core/CompanionModel.swift +++ b/Sources/PokeTokenBar/Core/CompanionModel.swift @@ -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건(순수 판정 결과) — 부수효과(인벤토리·알림)와 분리해 테스트 가능하게. @@ -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 @@ -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) } diff --git a/Sources/PokeTokenBar/Core/CompanionStore.swift b/Sources/PokeTokenBar/Core/CompanionStore.swift index 298c1637..ddaf5e63 100644 --- a/Sources/PokeTokenBar/Core/CompanionStore.swift +++ b/Sources/PokeTokenBar/Core/CompanionStore.swift @@ -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 } @@ -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 일 때만). 한도 알림과 독립. diff --git a/Sources/PokeTokenBar/Core/SaveTransfer.swift b/Sources/PokeTokenBar/Core/SaveTransfer.swift index df0bf394..4125a896 100644 --- a/Sources/PokeTokenBar/Core/SaveTransfer.swift +++ b/Sources/PokeTokenBar/Core/SaveTransfer.swift @@ -187,7 +187,7 @@ enum SaveTransfer { /// - **기기 환경설정**: 진행이 아니라 이 기기에서 보는 방식(`language`) → **현재 기기 값을 지킨다**. /// 일본어 Mac 의 세이브가 영어 Mac 의 UI 언어를 바꾸면 안 된다. /// - /// 계정 전역 원장(`candyGrantTier`)은 교체가 아니라 **key 별 max 병합**이다. 한도 창 key 는 계정 + /// 계정 전역 원장(`candyGrantTier`·`candyWindowEpoch`)은 교체가 아니라 **key 별 병합**이다. 한도 창 key 는 계정 /// 단위라 두 기기가 같은 창을 본다 — 더 오래된 세이브로 통째 교체하면 이미 지급한 창의 기록이 /// 사라져 같은 창에서 사탕이 재지급된다(보존만으로는 이 역방향을 못 막는다). static func rebasedForThisDevice(_ imported: CompanionState, @@ -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 { @@ -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 } + } } diff --git a/Sources/PokeTokenBar/Core/UsageStore.swift b/Sources/PokeTokenBar/Core/UsageStore.swift index 54427552..2b842154 100644 --- a/Sources/PokeTokenBar/Core/UsageStore.swift +++ b/Sources/PokeTokenBar/Core/UsageStore.swift @@ -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 ?? [] { @@ -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 @@ -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 } diff --git a/Tests/PokeTokenBarTests/AdditionalClaudeAccountsTests.swift b/Tests/PokeTokenBarTests/AdditionalClaudeAccountsTests.swift index 935ba439..a4d1405c 100644 --- a/Tests/PokeTokenBarTests/AdditionalClaudeAccountsTests.swift +++ b/Tests/PokeTokenBarTests/AdditionalClaudeAccountsTests.swift @@ -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 diff --git a/Tests/PokeTokenBarTests/RareCandyTests.swift b/Tests/PokeTokenBarTests/RareCandyTests.swift index aa0b8ab5..4b992f83 100644 --- a/Tests/PokeTokenBarTests/RareCandyTests.swift +++ b/Tests/PokeTokenBarTests/RareCandyTests.swift @@ -15,8 +15,9 @@ private let rcLinear3 = rcLine(base: 1, tree: rcNode(1, [rcNode(2, [rcNode(3)])] private let rcNoEvo = rcLine(base: 20, tree: rcNode(20)) // 커먼 1형태: 750M 단일 private let rcNow = Date(timeIntervalSince1970: 1_700_000_000) -private func w(_ key: String, _ kind: WindowClass, _ util: Double, name: String = "T") -> CandyWindow { - CandyWindow(key: key, name: name, kind: kind, utilization: util) +private func w(_ key: String, _ kind: WindowClass, _ util: Double, name: String = "T", + epoch: String? = nil) -> CandyWindow { + CandyWindow(key: key, name: name, kind: kind, utilization: util, epoch: epoch) } /// line() 이 throw 하는 provider — 라인 미로딩(오프라인/재시작 직후) 상태 재현용. @@ -32,20 +33,26 @@ private struct RCLineThrows: PokeProviding { final class CandyGrantEvaluationTests: XCTestCase { func testSessionGrantsOne() { var tier: [String: Int] = [:] - let grants = CompanionStore.evaluateCandyGrants(windows: [w("s", .session, 100)], grantTier: &tier) + var epochs: [String: String] = [:] + let grants = CompanionStore.evaluateCandyGrants( + windows: [w("s", .session, 100)], grantTier: &tier, windowEpoch: &epochs) XCTAssertEqual(grants.map(\.count), [1]) XCTAssertEqual(tier["s"], 1) } func testWeeklyGrantsFive() { var tier: [String: Int] = [:] - let grants = CompanionStore.evaluateCandyGrants(windows: [w("wk", .weekly, 100)], grantTier: &tier) + var epochs: [String: String] = [:] + let grants = CompanionStore.evaluateCandyGrants( + windows: [w("wk", .weekly, 100)], grantTier: &tier, windowEpoch: &epochs) XCTAssertEqual(grants.map(\.count), [RareCandy.weeklyGrant]) } func testBelow100NoGrant() { var tier: [String: Int] = [:] - let grants = CompanionStore.evaluateCandyGrants(windows: [w("s", .session, 99.9)], grantTier: &tier) + var epochs: [String: String] = [:] + let grants = CompanionStore.evaluateCandyGrants( + windows: [w("s", .session, 99.9)], grantTier: &tier, windowEpoch: &epochs) XCTAssertTrue(grants.isEmpty) XCTAssertNil(tier["s"]) } @@ -53,37 +60,76 @@ final class CandyGrantEvaluationTests: XCTestCase { /// 같은 tier 유지 중엔 재지급 안 함(80·81·84… 억제의 사탕 버전). func testNoDoubleGrantWhileAt100() { var tier: [String: Int] = [:] - _ = CompanionStore.evaluateCandyGrants(windows: [w("s", .session, 100)], grantTier: &tier) - let again = CompanionStore.evaluateCandyGrants(windows: [w("s", .session, 100)], grantTier: &tier) + var epochs: [String: String] = [:] + _ = CompanionStore.evaluateCandyGrants( + windows: [w("s", .session, 100, epoch: "E1")], grantTier: &tier, windowEpoch: &epochs) + let again = CompanionStore.evaluateCandyGrants( + windows: [w("s", .session, 100, epoch: "E1")], grantTier: &tier, windowEpoch: &epochs) XCTAssertTrue(again.isEmpty, "이미 지급한 창은 재지급 안 함") } /// 100% 아래로 내려가면 재무장(맵에서 제거) → 다시 채우면 재지급. func testRearmAfterDropBelow100() { var tier: [String: Int] = [:] - _ = CompanionStore.evaluateCandyGrants(windows: [w("s", .session, 100)], grantTier: &tier) - _ = CompanionStore.evaluateCandyGrants(windows: [w("s", .session, 40)], grantTier: &tier) + var epochs: [String: String] = [:] + _ = CompanionStore.evaluateCandyGrants( + windows: [w("s", .session, 100)], grantTier: &tier, windowEpoch: &epochs) + _ = CompanionStore.evaluateCandyGrants( + windows: [w("s", .session, 40)], grantTier: &tier, windowEpoch: &epochs) XCTAssertNil(tier["s"], "경고선 아래 → 제거(재무장)") - let regrant = CompanionStore.evaluateCandyGrants(windows: [w("s", .session, 100)], grantTier: &tier) + let regrant = CompanionStore.evaluateCandyGrants( + windows: [w("s", .session, 100)], grantTier: &tier, windowEpoch: &epochs) XCTAssertEqual(regrant.map(\.count), [1], "리셋 후 다시 채우면 재지급") } + /// [#326] 창이 리셋됐는데 앱이 중간 <100% 샘플을 못 보면(슬립·종료) util 은 계속 100%다. + /// key 는 안정(claude.fiveHour)이라 util-only 재무장은 안 되고, epoch(`resets_at`) 교체로 재무장한다. + func testRearmWhenWindowEpochAdvancesWhileStillAt100() { + var tier: [String: Int] = [:] + var epochs: [String: String] = [:] + _ = CompanionStore.evaluateCandyGrants( + windows: [w("claude.fiveHour", .session, 100, epoch: "2026-09-17T10:00:00Z")], + grantTier: &tier, windowEpoch: &epochs) + XCTAssertEqual(tier["claude.fiveHour"], 1) + + let grants = CompanionStore.evaluateCandyGrants( + windows: [w("claude.fiveHour", .session, 100, epoch: "2026-09-17T15:00:00Z")], + grantTier: &tier, windowEpoch: &epochs) + XCTAssertEqual(grants.map(\.count), [1], "새 창 epoch + 100% 는 재지급(슬립 중 리셋 누락 방지)") + XCTAssertEqual(epochs["claude.fiveHour"], "2026-09-17T15:00:00Z") + } + + /// epoch 를 처음 알게 된 것만으로 재지급하지 않는다(구세이브 → 신규 필드 도입 시 폭탄 방지). + func testLearningEpochForFirstTimeDoesNotRegrant() { + var tier: [String: Int] = ["claude.fiveHour": 1] + var epochs: [String: String] = [:] + let grants = CompanionStore.evaluateCandyGrants( + windows: [w("claude.fiveHour", .session, 100, epoch: "E1")], + grantTier: &tier, windowEpoch: &epochs) + XCTAssertTrue(grants.isEmpty, "nil→E1 은 재무장이 아니라 epoch 기록만") + XCTAssertEqual(epochs["claude.fiveHour"], "E1") + XCTAssertEqual(tier["claude.fiveHour"], 1) + } + /// 세션+주간+미달 혼합 — 세션 1 + 주간 5, 미달 창은 무시. func testMixedWindows() { var tier: [String: Int] = [:] + var epochs: [String: String] = [:] let grants = CompanionStore.evaluateCandyGrants(windows: [ w("claude.fiveHour", .session, 100), w("claude.sevenDay", .weekly, 100), w("codex.codex.primary", .session, 50), - ], grantTier: &tier) + ], grantTier: &tier, windowEpoch: &epochs) XCTAssertEqual(grants.reduce(0) { $0 + $1.count }, 1 + RareCandy.weeklyGrant) } /// 지급 grant 는 발화 창 이름을 담는다(알림 "왜 받는지"). func testGrantCarriesWindowName() { var tier: [String: Int] = [:] + var epochs: [String: String] = [:] let grants = CompanionStore.evaluateCandyGrants( - windows: [w("claude.fiveHour", .session, 100, name: "Claude 5시간 세션")], grantTier: &tier) + windows: [w("claude.fiveHour", .session, 100, name: "Claude 5시간 세션")], + grantTier: &tier, windowEpoch: &epochs) XCTAssertEqual(grants.first?.windowName, "Claude 5시간 세션") } @@ -129,6 +175,18 @@ final class RareCandyStoreTests: XCTestCase { return CompanionStore(provider: StubProvider(value: line), clock: { rcNow }, fileURL: url, rng: SeededRNG(seed: seed)) } + /// [#326] 시드 후 util 이 계속 100%여도 창 epoch 가 바뀌면 재지급. + func testGrantAfterSeedWhenEpochAdvancesWithoutUtilDip() { + let s = store(rcLinear3) + s.grantCandies(from: [w("claude.fiveHour", .session, 100, epoch: "E1")], limitsReady: true) // 시드 + XCTAssertEqual(s.rareCandyCount, 0) + s.grantCandies(from: [w("claude.fiveHour", .session, 100, epoch: "E1")], limitsReady: true) + XCTAssertEqual(s.rareCandyCount, 0, "같은 epoch 는 재지급 없음") + s.grantCandies(from: [w("claude.fiveHour", .session, 100, epoch: "E2")], limitsReady: true) + XCTAssertEqual(s.rareCandyCount, 1, "새 epoch + 100% → 지급") + XCTAssertEqual(s.state.candyWindowEpoch["claude.fiveHour"], "E2") + } + /// 시드+지급 헬퍼 — 빈 창으로 시드 완료 후, 유니크 세션 창을 100%로 올려 n개 지급. private func giveCandies(_ s: CompanionStore, _ n: Int) { s.grantCandies(from: [], limitsReady: true) // 시드(지급 0) diff --git a/Tests/PokeTokenBarTests/SaveTransferTests.swift b/Tests/PokeTokenBarTests/SaveTransferTests.swift index 63a3e5ff..2dab6927 100644 --- a/Tests/PokeTokenBarTests/SaveTransferTests.swift +++ b/Tests/PokeTokenBarTests/SaveTransferTests.swift @@ -532,7 +532,7 @@ final class SaveTransferTests: XCTestCase { "representativeSpeciesID", "representativeUnownForm", "dex", "collectedFinals", "inventory"] let deviceLedger: Set = ["installBaselineSet", "claimedTodayTokensByProvider", "lastDate"] - let accountLedger: Set = ["candyGrantTier", "candyFeatureSeeded"] + let accountLedger: Set = ["candyGrantTier", "candyWindowEpoch", "candyFeatureSeeded"] let devicePreference: Set = ["language"] let classified = progress.union(deviceLedger).union(accountLedger).union(devicePreference) diff --git a/docs/reference/defect-log.md b/docs/reference/defect-log.md index 81c832c0..b09bf659 100644 --- a/docs/reference/defect-log.md +++ b/docs/reference/defect-log.md @@ -850,6 +850,15 @@ read_when: ## 알림 +- **사탕 재무장도 util dip 만 믿지 마라 — 창 epoch 를 같이 보라.** 지급 key 는 안정 + (`claude.fiveHour`)이어야 알림 dedup 과 같은 이유로 `resets_at` 을 key 에 넣으면 안 된다. 그런데 + 재무장을 *오직* utilization<100 관측에만 걸면, Mac 이 리셋 구간을 잠자거나 앱이 꺼져 있는 동안 + 창이 바뀌고 다음 poll 이 다시 100%면 `candyGrantTier` 가 영원히 1 로 남아 "한도를 여러 번 + 찍었는데 사탕이 안 온다"가 된다(#326). 해결: `candyWindowEpoch[key]=resets_at` 을 영속하고, 같은 + key 에서 epoch 가 바뀌면 util 과 무관하게 tier 를 비운 뒤 100%면 재지급. nil→첫 epoch 는 재무장이 + 아니다(구세이브 업그레이드 폭탄 방지). 회귀: `testRearmWhenWindowEpochAdvancesWhileStillAt100`· + `testGrantAfterSeedWhenEpochAdvancesWithoutUtilDip`·`testLearningEpochForFirstTimeDoesNotRegrant`. + - **휘발성 필드를 dedup/identity 키에 쓰지 마라.** 매 fetch/refresh 마다 값이 변하는 필드(예: rolling 한도 창의 `resets_at`)를 알림 중복방지 키에 넣으면 매번 새 키가 되어 dedup 이 무력화된다 — 주간 한도 알림이 80·81·84…갱신마다 반복되던 회귀. 임계값 알림은 **엣지 트리거**(직전 tier 보다 높아진 순간만