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
12 changes: 10 additions & 2 deletions PokeTokenBarShared/Sources/PokeTokenBarShared/PhonePayload.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -498,5 +505,6 @@ public struct PhoneShopEntry: Codable, Sendable, Equatable, Identifiable {
self.canAfford = canAfford
self.iconName = iconName
self.fallbackEmoji = fallbackEmoji
self.lockedReason = lockedReason
}
}
39 changes: 27 additions & 12 deletions PokeTokenBariOS/Sources/ShopView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
Expand Down
9 changes: 7 additions & 2 deletions Sources/PokeTokenBar/Core/CompanionStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -784,6 +788,7 @@ final class CompanionStore {
/// 알 구매 가능 — 폐기할 활성 포켓몬이 있고 지갑이 그 티어 가격 이상일 때만.
/// 알 상태에서도 살 수 있게 하는 안은 채택하지 않았다(기존 새 알과 게이트 통일) — 알끼리 교체하는
/// 동작을 새로 만들지 않고, 상점의 알은 언제나 "지금 개체를 놓아주고 다시 뽑는다"는 한 가지 의미만 갖는다.
/// 항목 자체는 알 상태에서도 상점에 남는다(shopEntries) — 이 게이트는 구매만 막는다.
func canBuyEgg(_ tier: Rarity?) -> Bool {
// 파는 티어인지 먼저 확인한다 — 만족 불가능한 보증(전설: capture_rate 로 표현 불가)을 사면
// 두 롤 경로 모두 후보가 0개라 알이 영영 안 깨지고, 부화가 없으니 보증도 안 풀리며,
Expand Down
10 changes: 10 additions & 0 deletions Sources/PokeTokenBar/Core/Localization.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
9 changes: 7 additions & 2 deletions Sources/PokeTokenBar/PokeTokenBarApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
}
Expand Down
34 changes: 24 additions & 10 deletions Sources/PokeTokenBar/UI/ShopView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -125,6 +126,7 @@ private struct ShopItemCard: View {

/// 알 카드 — 구매 = 즉시 현재 포켓몬 폐기 후 새 알로. `tier` 는 보증 등급 하한(nil = 보증 없는 기본 알).
/// 인라인 2단계 확인: 일반은 1회, 이로치면 한 번 더(사고 폐기 방지). 성공하면 Home 으로 전환해 새 알을 보여준다.
/// 알 상태(활성 없음)에서도 카드는 노출하되 구매 버튼만 비활성 + 사유 한 줄(eggShopLockedHint).
///
/// 등급 알의 시각 구분은 **카드의 등급 배지**로만 한다 — 알 스프라이트는 한 장뿐이고, 메뉴바·플로팅 펫은
/// 기존 알 그대로 둔다(새 에셋 없이 구분이 서는 최소 범위).
Expand Down Expand Up @@ -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:
Expand Down
13 changes: 13 additions & 0 deletions Tests/PokeTokenBarTests/PhonePayloadCodableTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
55 changes: 44 additions & 11 deletions Tests/PokeTokenBarTests/ShopTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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, "더 비싼 등급 알은 잔액 부족")
}
}
Loading