diff --git a/PokeTokenBarShared/Sources/PokeTokenBarShared/PhonePayload.swift b/PokeTokenBarShared/Sources/PokeTokenBarShared/PhonePayload.swift index 72957cd31..7e7502208 100644 --- a/PokeTokenBarShared/Sources/PokeTokenBarShared/PhonePayload.swift +++ b/PokeTokenBarShared/Sources/PokeTokenBarShared/PhonePayload.swift @@ -476,16 +476,23 @@ public struct PhoneShopEntry: Codable, Sendable, Equatable, Identifiable { public let isPassive: Bool /// A passive item already purchased — renders as "Owned" instead of a price CTA. public let isOwned: Bool - /// Spendable tokens currently cover the price. + /// Spendable tokens currently cover the price. Affordability only — a listing can be + /// affordable and still unbuyable (see `lockedReason`). public let canAfford: Bool /// PokéAPI item sprite filename (…/sprites/items/{name}.png). nil = emoji fallback. public let iconName: String? /// Emoji fallback when the sprite is unavailable (eggs always use this). public let fallbackEmoji: String + /// Pre-localized one-line reason this listing cannot be bought right now for a reason + /// other than the balance (today: shop eggs during the egg stage — nothing to reroll). + /// nil = no state block. Takes priority over the insufficient-balance label, mirroring + /// the Mac's EggCard. Older Macs omit the key; it decodes as nil. + public let lockedReason: String? public init(id: String, isEgg: Bool, name: String, itemDescription: String, price: Int, rarity: String?, ownedCount: Int, isPassive: Bool, - isOwned: Bool, canAfford: Bool, iconName: String?, fallbackEmoji: String) { + isOwned: Bool, canAfford: Bool, iconName: String?, fallbackEmoji: String, + lockedReason: String? = nil) { self.id = id self.isEgg = isEgg self.name = name @@ -498,5 +505,6 @@ public struct PhoneShopEntry: Codable, Sendable, Equatable, Identifiable { self.canAfford = canAfford self.iconName = iconName self.fallbackEmoji = fallbackEmoji + self.lockedReason = lockedReason } } diff --git a/PokeTokenBariOS/Sources/ShopView.swift b/PokeTokenBariOS/Sources/ShopView.swift index f0517cec9..b1091295b 100644 --- a/PokeTokenBariOS/Sources/ShopView.swift +++ b/PokeTokenBariOS/Sources/ShopView.swift @@ -152,20 +152,35 @@ private struct ShopItemCard: View { Spacer() } } else { - HStack { - Text("Price \(TokenFormatter.compact(entry.price))") - .font(.caption2) - .foregroundStyle(.tertiary) - .monospacedDigit() - Spacer() - if entry.canAfford { - Label("Buy on Mac", systemImage: "desktopcomputer") - .font(.caption2.weight(.semibold)) - .foregroundStyle(.secondary) - } else { - Text("Not enough tokens") + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("Price \(TokenFormatter.compact(entry.price))") .font(.caption2) .foregroundStyle(.tertiary) + .monospacedDigit() + Spacer() + // A state block (egg stage) outranks the balance: the Mac sends the + // localized reason, so showing "Not enough tokens" over it would be a + // lie whenever the wallet is fine. Same priority as the Mac's EggCard. + if entry.lockedReason != nil { + Label("Buy on Mac", systemImage: "desktopcomputer") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.tertiary) + } else if entry.canAfford { + Label("Buy on Mac", systemImage: "desktopcomputer") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + } else { + Text("Not enough tokens") + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + if let reason = entry.lockedReason { + Text(reason) + .font(.caption2) + .foregroundStyle(.tertiary) + .fixedSize(horizontal: false, vertical: true) } } } diff --git a/Sources/PokeTokenBar/Core/CompanionStore.swift b/Sources/PokeTokenBar/Core/CompanionStore.swift index 88f7ab724..8d95706d4 100644 --- a/Sources/PokeTokenBar/Core/CompanionStore.swift +++ b/Sources/PokeTokenBar/Core/CompanionStore.swift @@ -727,17 +727,21 @@ final class CompanionStore { } } - /// 상점 표시 순서 — 판매 아이템 + (활성 포켓몬 있을 때) 알 3종을 하나의 가격 오름차순 목록으로 병합. + /// 상점 표시 순서 — 판매 아이템 + 알 3종을 하나의 가격 오름차순 목록으로 병합. /// 정렬 규칙은 purchasableItems 와 동일: 구매 완료한 보유형은 맨 아래, 나머지는 가격 저렴한 순. /// 알은 즉시 액션이라 '보유' 개념이 없어 가격 순서에만 참여한다. /// + /// 알은 활성 포켓몬이 없어도(알 상태) 목록에 남는다 — 구매는 `canBuyEgg` 의 `hasActive` 게이트가 + /// 막고, EggCard 가 비활성 버튼 + 사유 한 줄로 보여준다. 목록에서 통째로 빼면 "상점에 알이 원래 + /// 없다"로 읽혀서, 게이트는 유지하되 존재는 계속 보이게 한다. + /// /// 등급 알끼리 붙여 '티어 사다리'로 묶어 보이게 하는 안도 검토했으나 채택하지 않았다 — 지금의 순수 /// 가격 오름차순은 "알이 무조건 맨 아래로 append 돼 더 비싼 부적보다 아래에 놓이던" 표시 회귀를 /// 고치며 들어온 규칙이라(ShopTests 참조), 그룹 배치는 그 회귀를 부분적으로 되살린다. 티어 관계는 /// 카드의 등급 배지로 읽히게 한다. var shopEntries: [ShopEntry] { var entries: [ShopEntry] = purchasableItems.map { ShopEntry.item($0) } - if hasActive { entries += FreshEgg.shopTiers.map { ShopEntry.egg($0) } } + entries += FreshEgg.shopTiers.map { ShopEntry.egg($0) } return entries.sorted { a, b in let aDone = isPurchasedPassive(a) let bDone = isPurchasedPassive(b) @@ -784,6 +788,7 @@ final class CompanionStore { /// 알 구매 가능 — 폐기할 활성 포켓몬이 있고 지갑이 그 티어 가격 이상일 때만. /// 알 상태에서도 살 수 있게 하는 안은 채택하지 않았다(기존 새 알과 게이트 통일) — 알끼리 교체하는 /// 동작을 새로 만들지 않고, 상점의 알은 언제나 "지금 개체를 놓아주고 다시 뽑는다"는 한 가지 의미만 갖는다. + /// 항목 자체는 알 상태에서도 상점에 남는다(shopEntries) — 이 게이트는 구매만 막는다. func canBuyEgg(_ tier: Rarity?) -> Bool { // 파는 티어인지 먼저 확인한다 — 만족 불가능한 보증(전설: capture_rate 로 표현 불가)을 사면 // 두 롤 경로 모두 후보가 0개라 알이 영영 안 깨지고, 부화가 없으니 보증도 안 풀리며, diff --git a/Sources/PokeTokenBar/Core/Localization.swift b/Sources/PokeTokenBar/Core/Localization.swift index d2bca9808..527ab47a0 100644 --- a/Sources/PokeTokenBar/Core/Localization.swift +++ b/Sources/PokeTokenBar/Core/Localization.swift @@ -954,6 +954,16 @@ struct L { "Solte seu Pokémon atual e ganhe um ovo que garante \(r) ou melhor.", "Verabschiede dein aktuelles Pokémon und erhalte ein Ei, aus dem garantiert ein Pokémon der Seltenheitsstufe \(r) oder höher schlüpft.") } + /// 알 상태의 상점 알 카드 비활성 사유 — 항목은 보이되 구매 버튼 아래에 한 줄로 붙는다(EggCard). + var eggShopLockedHint: String { + t("지금 품고 있는 알이 부화하면 살 수 있어요.", + "Available once your current egg hatches.", + "いま抱えているタマゴが孵ると購入できます。", + "Disponible cuando eclosione tu huevo actual.", + "Disponible une fois ton œuf actuel éclos.", + "Disponível quando seu ovo atual chocar.", + "Verfügbar, sobald dein aktuelles Ei geschlüpft ist.") + } /// 인큐베이션 중 표시하는 보증 배지 — 어떤 알을 품고 있는지 한 줄로. func eggGuaranteeHint(_ tier: Rarity) -> String { let r = rarityLabel(tier) diff --git a/Sources/PokeTokenBar/PokeTokenBarApp.swift b/Sources/PokeTokenBar/PokeTokenBarApp.swift index d8d8ac25f..b3e8accb9 100644 --- a/Sources/PokeTokenBar/PokeTokenBarApp.swift +++ b/Sources/PokeTokenBar/PokeTokenBarApp.swift @@ -486,6 +486,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { /// 상점 목록(판매 아이템 + 알 3종) → 폰 읽기 전용 엔트리 매핑. 순서·가격·구매가능 판정은 /// CompanionStore.shopEntries/canBuy 를 그대로 따른다(폰은 재현하지 않고 표시만). /// 표시 문자열은 bag/dex 와 같은 규약으로 여기서 미리 현지화해 보낸다. + /// + /// 알은 알 상태(활성 없음)에서도 목록에 남는다 — Mac 의 EggCard 와 같은 규약이다. 그래서 잔액과 + /// 상태 게이트를 분리해 보낸다: `canAfford` 는 지갑만 보고, 못 사는 사유는 `lockedReason` 한 줄로. + /// 합쳐서 `canBuyEgg` 하나로 보내면 폰이 잔액이 충분한데도 "토큰 부족"으로 그린다. static func phoneShopEntries(_ companion: CompanionStore) -> [PhoneShopEntry] { let l = companion.l return companion.shopEntries.map { entry in @@ -516,9 +520,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { ownedCount: 0, isPassive: false, isOwned: false, - canAfford: companion.canBuyEgg(tier), + canAfford: companion.availableTokens >= entry.price, iconName: nil, - fallbackEmoji: "🥚") + fallbackEmoji: "🥚", + lockedReason: companion.hasActive ? nil : l.eggShopLockedHint) } } } diff --git a/Sources/PokeTokenBar/UI/ShopView.swift b/Sources/PokeTokenBar/UI/ShopView.swift index 3c5706eb1..b02d19820 100644 --- a/Sources/PokeTokenBar/UI/ShopView.swift +++ b/Sources/PokeTokenBar/UI/ShopView.swift @@ -15,7 +15,8 @@ struct ShopView: View { VStack(alignment: .leading, spacing: 10) { walletHeader(l) // shopEntries = 판매 아이템 + 알 3종(보증 없음·고급 이상·희귀 이상)을 가격 오름차순으로 - // 병합한 단일 목록. 알은 활성 포켓몬이 있을 때만 포함된다(즉시 액션이라 ItemKind 가 아님). + // 병합한 단일 목록. 알은 항상 포함되고(즉시 액션이라 ItemKind 가 아님), 알 상태에선 + // EggCard 가 구매만 비활성으로 보여준다. ForEach(store.shopEntries, id: \.self) { entry in switch entry { case .item(let kind): @@ -125,6 +126,7 @@ private struct ShopItemCard: View { /// 알 카드 — 구매 = 즉시 현재 포켓몬 폐기 후 새 알로. `tier` 는 보증 등급 하한(nil = 보증 없는 기본 알). /// 인라인 2단계 확인: 일반은 1회, 이로치면 한 번 더(사고 폐기 방지). 성공하면 Home 으로 전환해 새 알을 보여준다. +/// 알 상태(활성 없음)에서도 카드는 노출하되 구매 버튼만 비활성 + 사유 한 줄(eggShopLockedHint). /// /// 등급 알의 시각 구분은 **카드의 등급 배지**로만 한다 — 알 스프라이트는 한 장뿐이고, 메뉴바·플로팅 펫은 /// 기존 알 그대로 둔다(새 에셋 없이 구분이 서는 최소 범위). @@ -174,15 +176,27 @@ private struct EggCard: View { private func controls(_ l: L) -> some View { switch stage { case .idle: - HStack { - Text("\(l.shopPriceLabel) \(TokenFormatter.compact(price))") - .font(.caption2).foregroundStyle(.tertiary).monospacedDigit() - Spacer() - if store.canBuyEgg(tier) { - Button(l.buy) { stage = .confirm } - .buttonStyle(.bordered).controlSize(.small) - } else { - Text(l.notEnoughTokens).font(.caption2).foregroundStyle(.tertiary) + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("\(l.shopPriceLabel) \(TokenFormatter.compact(price))") + .font(.caption2).foregroundStyle(.tertiary).monospacedDigit() + Spacer() + if !store.hasActive { + // 알 상태 — 리롤 대상이 없어 구매만 막는다(canBuyEgg 게이트). 항목을 숨기는 대신 + // 비활성 버튼으로 "상점에 있긴 하다"를 보이고, 사유는 아래 한 줄로. + Button(l.buy) {} + .buttonStyle(.bordered).controlSize(.small).disabled(true) + } else if store.canBuyEgg(tier) { + Button(l.buy) { stage = .confirm } + .buttonStyle(.bordered).controlSize(.small) + } else { + Text(l.notEnoughTokens).font(.caption2).foregroundStyle(.tertiary) + } + } + if !store.hasActive { + Text(l.eggShopLockedHint) + .font(.caption2).foregroundStyle(.tertiary) + .fixedSize(horizontal: false, vertical: true) } } case .confirm: diff --git a/Tests/PokeTokenBarTests/PhonePayloadCodableTests.swift b/Tests/PokeTokenBarTests/PhonePayloadCodableTests.swift index f1ee3ef17..74b509ef5 100644 --- a/Tests/PokeTokenBarTests/PhonePayloadCodableTests.swift +++ b/Tests/PokeTokenBarTests/PhonePayloadCodableTests.swift @@ -144,6 +144,19 @@ final class PhonePayloadCodableTests: XCTestCase { XCTAssertNil(payload.companion?.lineNodes) } + /// `lockedReason` 없는 구 Mac 의 상점 엔트리도 nil 로 디코드된다 — 새 폰이 구 Mac 과 붙었을 때 + /// 상점 전체가 빈 목록으로 떨어지지 않게 (PhonePayload 의 shop 은 실패 시 통째로 [] 가 된다). + func testShopEntryDecodesLegacyPayloadWithoutLockedReason() throws { + let legacy = Data(""" + {"id":"egg:plain","isEgg":true,"name":"Fresh Egg","itemDescription":"Reroll", + "price":100,"ownedCount":0,"isPassive":false,"isOwned":false,"canAfford":true, + "fallbackEmoji":"🥚"} + """.utf8) + let entry = try JSONDecoder().decode(PhoneShopEntry.self, from: legacy) + XCTAssertNil(entry.lockedReason) + XCTAssertTrue(entry.canAfford) + } + /// 확장 필드 왕복 + Antigravity 그룹이 orderedWindows 의 마지막에 온다. func testExtendedFieldsRoundTrip() throws { let limits = PhoneLimitStatus( diff --git a/Tests/PokeTokenBarTests/ShopTests.swift b/Tests/PokeTokenBarTests/ShopTests.swift index 8cfeedc2a..2342adb91 100644 --- a/Tests/PokeTokenBarTests/ShopTests.swift +++ b/Tests/PokeTokenBarTests/ShopTests.swift @@ -169,15 +169,25 @@ final class ShopTests: XCTestCase { XCTAssertEqual(prices, prices.sorted(), "가격 상수가 바뀌어도 오름차순 불변식 유지") } - /// 활성 포켓몬이 없으면(알 상태) 리롤 대상이 없어 알은 **등급 알까지 전부** 목록에서 빠진다. - /// 프리미엄 알만 알 상태에서 살 수 있게 하는 안은 채택하지 않았다 — 기존 새 알과 게이트를 통일한다. - func testShopEntriesOmitsFreshEggWhenNoActive() { - let s = store(used: 5_000_000_000) // active 없음 + /// 활성 포켓몬이 없어도(알 상태) 알 3종은 목록에 **남는다** — 숨기면 "상점에 알이 원래 없다"로 + /// 읽힌다. 대신 구매는 `canBuyEgg` 의 `hasActive` 게이트가 전부 막는다(EggCard 는 비활성 버튼 + + /// 사유 한 줄). 잔액이 충분한 상태로 검증해 게이트가 잔액이 아니라 hasActive 에서 걸림을 확인한다. + func testShopEntriesKeepsEggsVisibleButUnbuyableWhenNoActive() { + let s = store(used: 5_000_000_000) // active 없음, 잔액은 전 티어 가격 이상 XCTAssertFalse(s.hasActive) - XCTAssertEqual(s.shopEntries, [.item(.mint), .item(.rareCandy), .item(.shinyCharm)]) + XCTAssertEqual(s.shopEntries, + [.item(.mint), // 100M + .item(.rareCandy), // 500M + .egg(nil), // 1B + .egg(.uncommon), // 2.5B + .item(.shinyCharm), // 3B + .egg(.rare)]) // 4B for tier in FreshEgg.shopTiers { - XCTAssertFalse(s.shopEntries.contains(.egg(tier)), "알 상태에선 \(tier?.rawValue ?? "기본") 알도 미노출") + XCTAssertTrue(s.shopEntries.contains(.egg(tier)), "알 상태에서도 \(tier?.rawValue ?? "기본") 알은 노출 유지") + XCTAssertFalse(s.canBuyEgg(tier), "노출은 되지만 \(tier?.rawValue ?? "기본") 알 구매는 hasActive 게이트로 차단") + XCTAssertFalse(s.buyEgg(tier), "buyEgg 도 no-op — 토큰이 빠져나가면 안 된다") } + XCTAssertEqual(s.availableTokens, 5_000_000_000, "차단된 구매 시도로 잔액이 줄지 않는다") } // MARK: 폰 페이로드 매핑 (읽기 전용 상점 — AppDelegate.phoneShopEntries) @@ -231,10 +241,33 @@ final class ShopTests: XCTestCase { XCTAssertEqual(entries.last?.isPassive, true) } - /// 알 상태(활성 포켓몬 없음)에선 폰 목록에서도 알이 전부 빠진다 — shopEntries 게이트의 폰 미러. - func testPhoneShopEntriesOmitEggsWhenNoActive() { - let entries = AppDelegate.phoneShopEntries(store(used: 5_000_000_000)) - XCTAssertFalse(entries.contains(where: \.isEgg)) - XCTAssertEqual(entries.map(\.id), ["item:mint", "item:rareCandy", "item:shinyCharm"]) + /// 알 상태(활성 포켓몬 없음)에서도 폰 목록에 알 3종이 남는다 — Mac 의 새 규약(#261) 미러. + /// 잔액이 충분해도 못 사는 상태라, 폰은 `canAfford`(지갑)와 `lockedReason`(상태)을 분리해 받는다. + func testPhoneShopEntriesKeepEggsLockedWhenNoActive() { + let companion = store(used: 5_000_000_000) + let entries = AppDelegate.phoneShopEntries(companion) + XCTAssertEqual(entries.map(\.id), + ["item:mint", "item:rareCandy", "egg:plain", + "egg:uncommon", "item:shinyCharm", "egg:rare"], + "알 상태에서도 목록·정렬은 활성일 때와 같다") + let eggs = entries.filter(\.isEgg) + XCTAssertEqual(eggs.count, 3) + for egg in eggs { + XCTAssertTrue(egg.canAfford, "\(egg.id): 지갑은 가격을 덮는다 — 막는 건 상태뿐") + XCTAssertEqual(egg.lockedReason, companion.l.eggShopLockedHint, + "\(egg.id): 못 사는 사유를 현지화해 보낸다") + } + XCTAssertTrue(entries.filter { !$0.isEgg }.allSatisfy { $0.lockedReason == nil }, + "아이템은 상태 게이트가 없다 — 잔액만으로 판단") + } + + /// 활성 포켓몬이 있으면 알에 상태 게이트가 없고, 잔액 부족은 canAfford 로만 표현된다. + func testPhoneShopEggsUnlockedWithActiveAndPriceGatedByWallet() { + let entries = AppDelegate.phoneShopEntries(storeWithMon(used: FreshEgg.price)) + let eggs = Dictionary(uniqueKeysWithValues: entries.filter(\.isEgg).map { ($0.id, $0) }) + XCTAssertEqual(eggs.count, 3) + XCTAssertTrue(eggs.values.allSatisfy { $0.lockedReason == nil }) + XCTAssertTrue(eggs["egg:plain"]!.canAfford, "잔액 = 기본 알 가격 정확히 → 구매 가능") + XCTAssertFalse(eggs["egg:rare"]!.canAfford, "더 비싼 등급 알은 잔액 부족") } } diff --git a/docs/reference/defect-log.md b/docs/reference/defect-log.md index 717217d3d..4f5493c60 100644 --- a/docs/reference/defect-log.md +++ b/docs/reference/defect-log.md @@ -694,6 +694,24 @@ read_when: 리터럴 안의 `//` 를 주석으로 오인하면 진짜 결함을 놓친다(역검증에서 이 케이스로 반증함). 새 프로바이더 UI 를 붙일 땐 같은 부류의 형제 문구(여기선 `claudeAuthExpiredTitle/Hint`)를 먼저 찾아 문안 구조까지 맞춘다 — 문구만 새로 지으면 같은 화면에서 두 안내가 다른 말투로 갈린다. +- **폰 미러 페이로드의 필드는 그 이름이 약속한 것만 담는다 — 합성 게이트를 넣으면 폰이 이유를 못 고른다.** + `PhoneShopEntry.canAfford`("지갑이 가격을 덮나")에 `canBuyEgg`(= `hasActive && 잔액`)를 그대로 + 실었더니, 상점 알 카드가 알 상태에서 목록에 남게 된 업스트림 변경(#261) 뒤에 폰이 잔액이 충분한데도 + "Not enough tokens" 로 그렸다. 게이트가 합성돼 있으면 폰엔 false 만 도착해서 **"왜 못 사는지"를 + 고를 정보가 없다.** 축을 나눠 보낸다: 잔액은 `canAfford`, 상태 차단은 미리 현지화한 `lockedReason` + 한 줄(nil = 차단 없음), 표시 우선순위는 Mac 과 같게 상태 > 잔액. 폰이 표시만 하는 미러라 해도 + **판정 이유는 Mac 이 쪼개서 보내야** 한다 — 폰은 게임 규칙을 재현하지 않는다. + 선례상 이 결함은 매핑을 쓸 때가 아니라 *업스트림이 화면 규약을 바꿀 때* 드러난다. 업스트림 동기화가 + Mac UI 의 표시 규약(무엇이 목록에 남나·무엇이 비활성으로 보이나)을 건드리면 `phoneShopEntries` + 같은 미러 매핑과 iOS 뷰까지 같은 커밋에서 따라가는지 본다 — 미러는 컴파일로 깨지지 않아서 + 테스트가 없으면 조용히 어긋난다. 부류 스윕 확인분: 아이템 쪽 `canAfford: canBuy(kind)` 도 보유형 + 중복구매 금지를 합성해 담지만, `isOwned` 가 별도 축으로 같이 가고 iOS 뷰가 그 축을 먼저 분기해 + 잘못된 라벨이 나오지 않는다 — 축이 하나 더 붙으면 같은 결함이 된다. +- **폰 페이로드에 필드를 더할 땐 옵셔널 + 구 페이로드 디코드 테스트가 짝이다.** `PhonePayload` 는 구버전 + Mac 을 위해 섹션 단위로 `decodeIfPresent` 하는데, 그 안의 원소 타입이 새 **비옵셔널** 필드를 요구하면 + 원소 디코드가 던지고 섹션이 통째로 `[]` 이 된다 — 새 폰 + 구 Mac 조합에서 화면 하나가 빈다. + 새 필드는 옵셔널(합성 Codable 이 `decodeIfPresent` 로 읽는다)로 넣고, 그 필드가 없는 JSON 을 직접 + 디코드하는 테스트를 같이 넣는다(`testShopEntryDecodesLegacyPayloadWithoutLockedReason`). ## 에너지 (상시 표시 애니메이션)