From 3ae53ba5a4b594cd63f36068d440d203fe9dbe71 Mon Sep 17 00:00:00 2001 From: "Patrick.Park" Date: Thu, 3 Sep 2026 11:21:21 +0900 Subject: [PATCH 1/2] feat: keep shop egg cards visible during the egg stage with a disabled buy button (#261) During the egg stage the shop dropped all three egg cards, which read as "the shop doesn't sell eggs" rather than "you can't buy one right now". The purchase gate itself is intended and unchanged: a shop egg always means "release the current Pokemon and reroll", so there is nothing to reroll while incubating (canBuyEgg still requires an active Pokemon). - shopEntries no longer filters eggs by hasActive; price-ascending merge order is unchanged. - EggCard shows a disabled Buy button during the egg stage with a one-line reason underneath, localized in all seven languages. The state reason takes priority over the insufficient-balance label. - The "eggs omitted when no active" test is replaced by one pinning the new contract: eggs stay visible, canBuyEgg/buyEgg stay blocked for every tier, and a blocked attempt does not touch the wallet. Co-authored-by: p Co-authored-by: Claude Fable 5 (cherry picked from commit 56a354716de0e6a47db1817ba9451d52a1ffed08) --- .../PokeTokenBar/Core/CompanionStore.swift | 9 +++-- Sources/PokeTokenBar/Core/Localization.swift | 10 ++++++ Sources/PokeTokenBar/UI/ShopView.swift | 34 +++++++++++++------ Tests/PokeTokenBarTests/ShopTests.swift | 22 ++++++++---- 4 files changed, 57 insertions(+), 18 deletions(-) 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/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/ShopTests.swift b/Tests/PokeTokenBarTests/ShopTests.swift index 8cfeedc2a..70d0741fc 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) From deb6ade367933f90cb7bc2939b5ea4d8124f9774 Mon Sep 17 00:00:00 2001 From: Cory Eurom Date: Wed, 2 Sep 2026 20:16:55 -0700 Subject: [PATCH 2/2] fix(phone): mirror the shop's egg-stage lock instead of showing "Not enough tokens" Upstream #261 keeps the three shop egg cards visible during the egg stage and disables only the buy button. The fork's phone mirror inherited the visibility but not the reason: `PhoneShopEntry.canAfford` was fed `canBuyEgg`, which folds `hasActive` into the balance check, so the phone received a bare `false` and rendered "Not enough tokens" on a wallet that covers the price. - `canAfford` now reports affordability only; a new optional `lockedReason` carries the pre-localized one-line state reason (`eggShopLockedHint`), nil when nothing blocks it. - The iOS shop card shows that reason under a dimmed CTA and gives it priority over the balance label, matching the Mac's EggCard. - `lockedReason` is optional so payloads from older Macs still decode; a non-optional field would throw and drop the whole `shop` section. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012t7yGH4SM3icrTq2eGYG2R --- .../PokeTokenBarShared/PhonePayload.swift | 12 +++++- PokeTokenBariOS/Sources/ShopView.swift | 39 +++++++++++++------ Sources/PokeTokenBar/PokeTokenBarApp.swift | 9 ++++- .../PhonePayloadCodableTests.swift | 13 +++++++ Tests/PokeTokenBarTests/ShopTests.swift | 33 +++++++++++++--- docs/reference/defect-log.md | 18 +++++++++ 6 files changed, 103 insertions(+), 21 deletions(-) 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/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/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 70d0741fc..2342adb91 100644 --- a/Tests/PokeTokenBarTests/ShopTests.swift +++ b/Tests/PokeTokenBarTests/ShopTests.swift @@ -241,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`). ## 에너지 (상시 표시 애니메이션)