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
41 changes: 37 additions & 4 deletions Sources/PokeTokenBar/Core/AntigravityRateLimitsProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,14 @@ import Security

/// Antigravity 공식 한도 조회 추상화 — 실 구현 또는 테스트 스텁 주입.
public protocol AntigravityLimitsProviding: Sendable {
var hasTokenFile: Bool { get }
func fetch(allowKeychainPrompt: Bool) async throws -> AntigravityRateLimitStatus
}

extension AntigravityLimitsProviding {
public var hasTokenFile: Bool { false }
}

public struct AntigravityRateLimitsProvider: AntigravityLimitsProviding, Sendable {
public static let primaryURL = URL(string: "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary")!
public static let dailyURL = URL(string: "https://daily-cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary")!
Expand All @@ -23,8 +28,15 @@ public struct AntigravityRateLimitsProvider: AntigravityLimitsProviding, Sendabl
self.tokenCache = tokenCache
}

public var hasTokenFile: Bool {
tokenCache.hasTokenFile
}

public func fetch(allowKeychainPrompt: Bool = false) async throws -> AntigravityRateLimitStatus {
let token = try await tokenCache.accessToken(allowKeychainPrompt: allowKeychainPrompt)
// Claude 와 같은 부류(#300): 사용자 갱신은 아직 유효한 메모리 토큰을 믿지 않는다.
let token = try await tokenCache.accessToken(
allowKeychainPrompt: allowKeychainPrompt,
bypassCache: allowKeychainPrompt)
do {
return try await fetchStatus(accessToken: token)
} catch let error as LimitsError {
Expand Down Expand Up @@ -299,12 +311,33 @@ actor AntigravityTokenCache {
return .revoked
}

nonisolated var hasTokenFile: Bool {
Self.readTokenFile(urls: tokenFileURLs) != nil
}

static var defaultTokenFileURLs: [URL] {
var urls: [URL] = []
if let envPath = UsageEnvironment.value("ANTIGRAVITY_TOKEN_FILE")?.trimmingCharacters(in: .whitespacesAndNewlines),
!envPath.isEmpty {
urls.append(URL(fileURLWithPath: (envPath as NSString).expandingTildeInPath))
}
urls.append(AppStatePaths.directory().appendingPathComponent("antigravity-token.json"))

let home = FileManager.default.homeDirectoryForCurrentUser
return [
home.appendingPathComponent(".gemini/jetski-standalone-oauth-token"),
home.appendingPathComponent(".gemini/antigravity/jetski-standalone-oauth-token"),
let candidates = [
".gemini/antigravity-cli/antigravity-oauth-token",
".gemini/antigravity-cli/jetski-standalone-oauth-token",
".gemini/antigravity-ide/antigravity-oauth-token",
".gemini/antigravity-ide/jetski-standalone-oauth-token",
".gemini/antigravity/antigravity-oauth-token",
".gemini/antigravity/jetski-standalone-oauth-token",
".gemini/antigravity-oauth-token",
".gemini/jetski-standalone-oauth-token",
]
for candidate in candidates {
urls.append(home.appendingPathComponent(candidate))
}
return urls
}

static var defaultPersistentStoreURL: URL {
Expand Down
3 changes: 3 additions & 0 deletions Sources/PokeTokenBar/Core/AppEnv.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ enum AppEnv {
ProcessInfo.processInfo.environment["PTB_PARITY"] == "1"
}

/// 테스트에서 URLProtocol 등 가짜 네트워크를 등록하고 fetch 를 돌릴 때 게이트 바이패스
nonisolated(unsafe) static var allowLiveFetchForTesting: Bool = false

/// 사용자 상태 파일(Application Support 등)을 실제로 읽고 쓸 것인가.
///
/// 경로를 주입받았으면 항상 참 — 테스트가 격리된 임시 파일로 지속성을 검증하는 통로다.
Expand Down
6 changes: 6 additions & 0 deletions Sources/PokeTokenBar/Core/CollectionWeight.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/// Shared duplicate reduction for species and form selection. Keep every candidate selectable.
enum CollectionWeight {
static func adjusted(_ weight: Int, isCollected: Bool) -> Int {
isCollected ? max(1, weight / 2) : max(1, weight)
}
}
62 changes: 54 additions & 8 deletions Sources/PokeTokenBar/Core/CompanionModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,7 @@ struct MonState: Codable, Sendable {
var totalForms: Int
var isShiny = false // 부화 시 확정, 진화해도 유지
var nature: PokemonNature? // 부화 시 확정 (구버전 저장은 nil)
var unownForm: UnownForm?
/// 개체 고유 전투 프로필. 구버전 저장은 nil이며 `CompanionStore`가 한 번만 마이그레이션한다.
var profile: PokemonProfile?
var hasGrowthBoost = false
Expand All @@ -461,7 +462,7 @@ struct MonState: Codable, Sendable {
init(baseID: Int, pathIDs: [Int], plannedPathIDs: [Int]? = nil, stageIndex: Int, usedAtStage: Int,
rarity: Rarity, totalForms: Int, isShiny: Bool = false, nature: PokemonNature? = nil,
profile: PokemonProfile? = nil, hasGrowthBoost: Bool = false,
dittoDisguise: Int? = nil, dittoRevealed: Bool = false) {
dittoDisguise: Int? = nil, dittoRevealed: Bool = false, unownForm: UnownForm? = nil) {
self.baseID = baseID
self.pathIDs = pathIDs
if let plannedPathIDs, !plannedPathIDs.isEmpty {
Expand All @@ -479,6 +480,7 @@ struct MonState: Codable, Sendable {
self.hasGrowthBoost = hasGrowthBoost
self.dittoDisguise = dittoDisguise
self.dittoRevealed = dittoRevealed
self.unownForm = UnownForm.resolved(speciesID: baseID, form: unownForm)
}

// 하위호환 디코딩: 구버전 저장에 없는 부화 속성은 기본값.
Expand Down Expand Up @@ -507,6 +509,8 @@ struct MonState: Codable, Sendable {
hasGrowthBoost = try c.decodeIfPresent(Bool.self, forKey: .hasGrowthBoost) ?? false
dittoDisguise = try c.decodeIfPresent(Int.self, forKey: .dittoDisguise)
dittoRevealed = try c.decodeIfPresent(Bool.self, forKey: .dittoRevealed) ?? false
unownForm = UnownForm.resolved(speciesID: baseID,
form: try? c.decode(UnownForm.self, forKey: .unownForm))
}
}

Expand All @@ -522,6 +526,7 @@ struct DexEntry: Codable, Sendable, Identifiable {
var caughtAt: Date?
var isShiny = false
var nature: PokemonNature?
var unownForm: UnownForm?
/// The individual profile at graduation/release. Nil only for pre-profile saves until migration.
var profile: PokemonProfile?
/// 진화 체인 각 종의 다국어 이름(speciesID → langCode → name). 졸업 시 로드된 라인에서 저장 →
Expand All @@ -545,7 +550,8 @@ struct DexEntry: Codable, Sendable, Identifiable {
init(id: String = UUID().uuidString,
baseID: Int, finalID: Int, chainOrder: [Int], rarity: Rarity,
caughtAt: Date?, isShiny: Bool = false, nature: PokemonNature? = nil,
profile: PokemonProfile? = nil, names: [Int: [String: String]]? = nil, releasedAt: Date? = nil) {
profile: PokemonProfile? = nil, names: [Int: [String: String]]? = nil, releasedAt: Date? = nil,
unownForm: UnownForm? = nil) {
self.id = id
self.baseID = baseID
self.finalID = finalID
Expand All @@ -559,6 +565,7 @@ struct DexEntry: Codable, Sendable, Identifiable {
self.namesVersion = chainOrder.allSatisfy { names?[$0]?.isEmpty == false }
? Self.currentNamesVersion : nil
self.releasedAt = releasedAt
self.unownForm = UnownForm.resolved(speciesID: baseID, form: unownForm)
}

// 하위호환 디코딩 (MonState 와 동일 이유).
Expand All @@ -580,6 +587,8 @@ struct DexEntry: Codable, Sendable, Identifiable {
namesVersion = try? c.decodeIfPresent(Int.self, forKey: .namesVersion)
// 이 필드 이전에 저장된 항목은 전부 졸업분이다 — nil 이 곧 "졸업"이라 마이그레이션이 필요 없다.
releasedAt = try c.decodeIfPresent(Date.self, forKey: .releasedAt)
unownForm = UnownForm.resolved(speciesID: baseID,
form: try? c.decode(UnownForm.self, forKey: .unownForm))
}
}

Expand Down Expand Up @@ -617,6 +626,8 @@ struct CompanionState: Codable, Sendable {
var eggTier: Rarity?
// 알 상태에서 미리 롤해둔 부화 종(프리패칭) — 부화 순간 네트워크 딜레이 제거. 재시작에도 유지.
var pendingHatchID: Int?
/// The letter is chosen with the species so prefetch warms the exact sprite that will hatch.
var pendingUnownForm: UnownForm?
/// 오늘 사용량 적립 기준값 — 프로바이더별로 독립 관리한다.
///
/// `nil`은 aggregate `claimedTodayTokens`만 가지고 있던 구버전 세이브가 아직 첫 유효
Expand All @@ -632,6 +643,7 @@ struct CompanionState: Codable, Sendable {
// 종 단위 선택이라 성격 같은 개체 정보는 들고 있지 않는다. 선택 가능한 범위는 도감과 동일하게
// 졸업분 + 현재 개체의 도달 단계이며, 그 범위에서 빠지면 reconcileRepresentativeSelection 이 nil 로 복구한다.
var representativeSpeciesID: Int? = nil
var representativeUnownForm: UnownForm? = nil
// 도감
var dex: [DexEntry] = []
// 소유한 (base,final) 쌍 — 분기 다양성용
Expand All @@ -658,6 +670,8 @@ struct CompanionState: Codable, Sendable {
// 모르는 rawValue 는 nil(보증 없음)로 강등 — 관대 디코딩의 안전한 방향(있지도 않은 보증을 만들지 않는다).
eggTier = c.lenientOptional(Rarity.self, forKey: .eggTier)
pendingHatchID = c.lenientOptional(Int.self, forKey: .pendingHatchID)
pendingUnownForm = UnownForm.resolved(speciesID: pendingHatchID ?? 0,
form: c.lenientOptional(UnownForm.self, forKey: .pendingUnownForm))
if c.contains(.claimedTodayTokensByProvider) {
claimedTodayTokensByProvider = c.lenient([String: Int].self,
forKey: .claimedTodayTokensByProvider,
Expand All @@ -671,6 +685,8 @@ struct CompanionState: Codable, Sendable {
// active 손상(빈 pathIDs 등) → 알로 폴백하되 도감·인벤토리는 보존.
active = c.lenientOptional(MonState.self, forKey: .active)
representativeSpeciesID = c.lenientOptional(Int.self, forKey: .representativeSpeciesID)
representativeUnownForm = UnownForm.resolved(speciesID: representativeSpeciesID ?? 0,
form: c.lenientOptional(UnownForm.self, forKey: .representativeUnownForm))
// 도감은 항목별 격리 — 손상 항목 하나가 도감 전체를 날리지 않게.
dex = c.lenient([Lossy<DexEntry>].self, forKey: .dex, default: []).compactMap(\.value)
collectedFinals = c.lenient(Set<String>.self, forKey: .collectedFinals, default: [])
Expand All @@ -682,31 +698,61 @@ struct CompanionState: Codable, Sendable {

/// 졸업 기록 또는 현재 개체가 실제로 도달한 단계에 이 종이 포함되는가.
/// 도감 전체 표시 모델을 만들지 않고 대표 종 하나만 확인하는 경량 경로다.
func ownsSpecies(_ speciesID: Int) -> Bool {
if dex.contains(where: { $0.chainOrder.contains(speciesID) }) { return true }
func ownsSpecies(_ speciesID: Int, unownForm: UnownForm? = nil) -> Bool {
let form = UnownForm.resolved(speciesID: speciesID, form: unownForm)
if dex.contains(where: {
$0.chainOrder.contains(speciesID)
&& UnownForm.resolved(speciesID: speciesID, form: $0.unownForm) == form
}) { return true }
guard let active else { return false }
return active.pathIDs.prefix(active.stageIndex + 1).contains(speciesID)
&& UnownForm.resolved(speciesID: speciesID, form: active.unownForm) == form
}

func hasCollectedFinal(forBaseID baseID: Int) -> Bool {
collectedFinals.contains { $0.hasPrefix("\(baseID):") }
}

/// Match Pokédex ownership, including the active Pokémon and released catch records.
/// Normal and shiny individuals of the same form contribute once.
var collectedUnownForms: Set<UnownForm> {
var forms = Set(dex.filter { $0.chainOrder.contains(UnownForm.speciesID) }.compactMap {
UnownForm.resolved(speciesID: UnownForm.speciesID, form: $0.unownForm)
})
if let active, active.pathIDs.prefix(active.stageIndex + 1).contains(UnownForm.speciesID),
let form = UnownForm.resolved(speciesID: UnownForm.speciesID, form: active.unownForm) {
forms.insert(form)
}
return forms
}

/// 보유한 특정 종의 이로치 여부. 졸업 기록과 현재 도달 단계만 훑으며 이름·정렬·희귀도 등
/// 도감 표시 모델은 계산하지 않는다. 위장 중인 메타몽의 이로치는 리빌 전까지 숨긴다.
func ownsShinySpecies(_ speciesID: Int) -> Bool {
if dex.contains(where: { $0.isShiny && $0.chainOrder.contains(speciesID) }) { return true }
func ownsShinySpecies(_ speciesID: Int, unownForm: UnownForm? = nil) -> Bool {
let form = UnownForm.resolved(speciesID: speciesID, form: unownForm)
if dex.contains(where: {
$0.isShiny && $0.chainOrder.contains(speciesID)
&& UnownForm.resolved(speciesID: speciesID, form: $0.unownForm) == form
}) { return true }
guard let active,
active.pathIDs.prefix(active.stageIndex + 1).contains(speciesID),
UnownForm.resolved(speciesID: speciesID, form: active.unownForm) == form,
active.isShiny else { return false }
return active.dittoDisguise == nil || active.dittoRevealed
}

/// 대표 포켓몬은 사용자가 현재 보유한 종만 가리킨다. Fresh Egg·메타몽 리빌·손편집 세이브가
/// 유령 종을 메뉴바와 플로팅 펫에 영구히 남기지 않게 한다.
mutating func reconcileRepresentativeSelection() {
guard let selected = representativeSpeciesID else { return }
if !ownsSpecies(selected) { representativeSpeciesID = nil }
guard let selected = representativeSpeciesID else {
representativeUnownForm = nil
return
}
representativeUnownForm = UnownForm.resolved(speciesID: selected, form: representativeUnownForm)
if !ownsSpecies(selected, unownForm: representativeUnownForm) {
representativeSpeciesID = nil
representativeUnownForm = nil
}
}
}

Expand Down
Loading