diff --git a/Sources/PokeTokenBar/Core/CompanionStore.swift b/Sources/PokeTokenBar/Core/CompanionStore.swift index 5cc470fd..51322805 100644 --- a/Sources/PokeTokenBar/Core/CompanionStore.swift +++ b/Sources/PokeTokenBar/Core/CompanionStore.swift @@ -178,6 +178,14 @@ final class CompanionStore { if a.dittoDisguise != nil && !a.dittoRevealed { return false } // 위장 중엔 이로치 숨김(리빌 때 공개) return a.isShiny } + /// 새 알(리롤) 구매 시 실수로 놓아주지 않도록 2단계 확인이 필요한 고가치 개체인지 판정. + /// 이로치(shiny)이거나 전설(legendary)인 경우에만 2단계 경고를 띄운다. + /// 희귀(rare)는 고급/희귀 알의 반복 리롤 피로도(alert fatigue)를 방지하기 위해 일반 확인만 거친다. + /// 위장 중인 메타몽은 `currentIsShiny`(=false)와 `rarity`(=.common)의 불변식을 그대로 따라 + /// 리빌 전 정체를 누설하지 않는다. + var isHighValueCompanion: Bool { + currentIsShiny || rarity == .legendary + } var currentNature: PokemonNature? { state.active?.nature } var growthMultiplier: Int? { state.active?.hasGrowthBoost == true ? PokemonBalance.repeatGrowthMultiplier : nil diff --git a/Sources/PokeTokenBar/Core/Localization.swift b/Sources/PokeTokenBar/Core/Localization.swift index be0e78c3..db913546 100644 --- a/Sources/PokeTokenBar/Core/Localization.swift +++ b/Sources/PokeTokenBar/Core/Localization.swift @@ -1094,7 +1094,9 @@ struct L { "\(monName) verabschieden und gegen \(eggName) tauschen?") } var freshEggShinyWarning: String { t("⚠️ 이로치 포켓몬이에요! 정말 놓아줄까요?", "⚠️ This one is shiny! Really send it off?", "⚠️ 色違いです!本当に手放しますか?", "⚠️ ¡Este es variocolor! ¿Seguro que quieres soltarlo?", "⚠️ Celui-ci est chromatique ! Vraiment le laisser partir ?", "⚠️ Esse é shiny! Quer mesmo soltar?", "⚠️ Dieses Pokémon ist schillernd! Wirklich verabschieden?") } + var freshEggLegendaryWarning: String { t("⚠️ 전설 포켓몬이에요! 정말 놓아줄까요?", "⚠️ This is a Legendary Pokémon! Really send it off?", "⚠️ 伝説のポケモンです!本当に手放しますか?", "⚠️ ¡Es un Pokémon legendario! ¿Seguro que quieres soltarlo?", "⚠️ C'est un Pokémon légendaire ! Vraiment le laisser partir ?", "⚠️ Esse é um Pokémon lendário! Quer mesmo soltar?", "⚠️ Dieses Pokémon ist legendär! Wirklich verabschieden?") } var freshEggDiscardShiny: String { t("이로치 놓아주기", "Send shiny off", "手放す", "Soltar variocolor", "Laisser partir le chromatique", "Soltar o shiny", "Schillerndes Pokémon verabschieden") } + var freshEggDiscardValuable: String { t("놓아주기", "Send off", "手放す", "Soltar", "Laisser partir", "Soltar", "Verabschieden") } // MARK: 사탕 획득 알림 ("왜 받는지" = 토큰 한도를 다 채운 수고에 대한 보상) func notifCandyTitle(item: String, count: Int) -> String { diff --git a/Sources/PokeTokenBar/UI/ShopView.swift b/Sources/PokeTokenBar/UI/ShopView.swift index cb33399d..e4521cf2 100644 --- a/Sources/PokeTokenBar/UI/ShopView.swift +++ b/Sources/PokeTokenBar/UI/ShopView.swift @@ -136,7 +136,7 @@ private struct EggCard: View { let nav: PopoverNavigation let tier: Rarity? @State private var stage: Stage = .idle - private enum Stage { case idle, confirm, shinyConfirm } + private enum Stage { case idle, confirm, preciousConfirm } private var price: Int { store.price(of: .egg(tier)) } @@ -204,20 +204,20 @@ private struct EggCard: View { Text(l.eggConfirm(store.displayName, l.eggName(tier))) .font(.caption2).foregroundStyle(.secondary).lineLimit(2) Spacer() - // 이로치면 한 번 더 경고, 아니면 즉시 실행. + // 이로치나 전설 등 고가치 포켓몬이면 한 번 더 경고, 아니면 즉시 실행. Button(l.buy) { - if store.currentIsShiny { stage = .shinyConfirm } else { commit() } + if store.isHighValueCompanion { stage = .preciousConfirm } else { commit() } } .buttonStyle(.borderedProminent).controlSize(.small) Button(l.cancel) { stage = .idle } .buttonStyle(.borderless).controlSize(.small) } - case .shinyConfirm: + case .preciousConfirm: HStack(spacing: 8) { - Text(l.freshEggShinyWarning) + Text(store.rarity == .legendary ? l.freshEggLegendaryWarning : l.freshEggShinyWarning) .font(.caption2.weight(.semibold)).foregroundStyle(.orange).lineLimit(2) Spacer() - Button(l.freshEggDiscardShiny) { commit() } + Button(store.currentIsShiny && store.rarity != .legendary ? l.freshEggDiscardShiny : l.freshEggDiscardValuable) { commit() } .buttonStyle(.borderedProminent).controlSize(.small).tint(.orange) Button(l.cancel) { stage = .idle } .buttonStyle(.borderless).controlSize(.small) diff --git a/Tests/PokeTokenBarTests/PreciousCompanionGuardTests.swift b/Tests/PokeTokenBarTests/PreciousCompanionGuardTests.swift new file mode 100644 index 00000000..337bd850 --- /dev/null +++ b/Tests/PokeTokenBarTests/PreciousCompanionGuardTests.swift @@ -0,0 +1,102 @@ +import XCTest +@testable import PokeTokenBar + +private struct NoOpProvider: PokeProviding { + func line(baseSpeciesID: Int) async throws -> EvoLine { throw URLError(.notConnectedToInternet) } + func baseSpeciesIndex() async throws -> [BaseSpecies] { [] } + func baseSpecies(id: Int) async throws -> BaseSpecies? { nil } +} + +@MainActor +final class PreciousCompanionGuardTests: XCTestCase { + private let now = Date(timeIntervalSince1970: 1_700_000_000) + + private func store( + active: Bool = true, + rarity: Rarity = .common, + shiny: Bool = false, + dittoDisguise: Int? = nil, + dittoRevealed: Bool = false + ) -> CompanionStore { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("guard-\(UUID().uuidString).json") + let disguiseField = dittoDisguise != nil ? ",\"dittoDisguise\":\(dittoDisguise!),\"dittoRevealed\":\(dittoRevealed)" : "" + let mon = "{\"baseID\":10,\"pathIDs\":[10],\"stageIndex\":0,\"usedAtStage\":200000000," + + "\"rarity\":\"\(rarity.rawValue)\",\"totalForms\":3,\"isShiny\":\(shiny)\(disguiseField)}" + let json = "{\"installBaselineSet\":true,\"usedSinceInstall\":5000000000,\"spentTokens\":0," + + "\"lastDate\":\"d\",\"active\":\(active ? mon : "null"),\"dex\":[],\"collectedFinals\":[]}" + try? json.data(using: .utf8)!.write(to: url) + return CompanionStore(provider: NoOpProvider(), clock: { self.now }, fileURL: url, rng: SeededRNG(seed: 1)) + } + + func testHighValueCompanionIdentifiesLegendaryNonShiny() { + let s = store(rarity: .legendary, shiny: false) + XCTAssertEqual(s.rarity, .legendary) + XCTAssertFalse(s.currentIsShiny) + XCTAssertTrue(s.isHighValueCompanion, "전설 포켓몬은 비-이로치라도 고가치 개체로 판정되어 2단계 확인을 거친다") + } + + func testHighValueCompanionIdentifiesShinyNonLegendary() { + let s = store(rarity: .common, shiny: true) + XCTAssertEqual(s.rarity, .common) + XCTAssertTrue(s.currentIsShiny) + XCTAssertTrue(s.isHighValueCompanion, "이로치 포켓몬은 일반 등급이라도 고가치 개체로 판정된다") + } + + func testHighValueCompanionIdentifiesShinyLegendary() { + let s = store(rarity: .legendary, shiny: true) + XCTAssertEqual(s.rarity, .legendary) + XCTAssertTrue(s.currentIsShiny) + XCTAssertTrue(s.isHighValueCompanion, "이로치 전설 포켓몬은 고가치 개체로 판정된다") + } + + func testHighValueCompanionExcludesCommonNonShiny() { + let s = store(rarity: .common, shiny: false) + XCTAssertFalse(s.isHighValueCompanion, "일반 비-이로치 포켓몬은 2단계 경고 없이 즉시 교체 가능") + } + + func testHighValueCompanionExcludesUncommonNonShiny() { + let s = store(rarity: .uncommon, shiny: false) + XCTAssertFalse(s.isHighValueCompanion, "고급 비-이로치 포켓몬은 일반 확인만 거친다") + } + + func testHighValueCompanionExcludesRareNonShinyToAvoidRerollFatigue() { + let s = store(rarity: .rare, shiny: false) + XCTAssertEqual(s.rarity, .rare) + XCTAssertFalse(s.currentIsShiny) + XCTAssertFalse(s.isHighValueCompanion, "희귀(rare) 등급은 희귀 알 반복 리롤 피로도를 막기 위해 2단계 경고 대상에서 제외한다") + } + + func testHighValueCompanionIsFalseWhenNoActive() { + let s = store(active: false) + XCTAssertFalse(s.hasActive) + XCTAssertFalse(s.isHighValueCompanion) + } + + func testDisguisedDittoDoesNotLeakHighValueStatusBeforeReveal() { + // 이로치 메타몽이 일반 포켓몬으로 위장 중인 경우 + let s = store(rarity: .common, shiny: true, dittoDisguise: 10, dittoRevealed: false) + XCTAssertFalse(s.currentIsShiny, "위장 중인 메타몽은 이로치를 숨긴다") + XCTAssertEqual(s.rarity, .common, "위장 중인 메타몽의 외견 등급은 common") + XCTAssertFalse(s.isHighValueCompanion, "위장 중에는 고가치 확인을 띄워 정체를 누설(leak)하지 않는다") + + // 리빌된 메타몽인 경우 + let revealed = store(rarity: .rare, shiny: true, dittoDisguise: 10, dittoRevealed: true) + XCTAssertTrue(revealed.currentIsShiny) + XCTAssertTrue(revealed.isHighValueCompanion, "리빌 후에는 이로치가 드러나 고가치 개체로 판정된다") + } + + func testLocalizationStringsCoverAllSevenLanguages() { + for lang in AppLanguage.allCases { + let l = L(lang) + XCTAssertFalse(l.freshEggLegendaryWarning.isEmpty, "전설 경고 문구가 모든 언어(\(lang))에 존재해야 함") + XCTAssertTrue(l.freshEggLegendaryWarning.hasPrefix("⚠️"), "경고 접두사 ⚠️ 포함") + XCTAssertFalse(l.freshEggDiscardValuable.isEmpty, "놓아주기 버튼 라벨이 모든 언어(\(lang))에 존재해야 함") + } + + // 한국어와 영어 문구 특정 검증 + XCTAssertTrue(L(.ko).freshEggLegendaryWarning.contains("전설 포켓몬")) + XCTAssertEqual(L(.ko).freshEggDiscardValuable, "놓아주기") + XCTAssertTrue(L(.en).freshEggLegendaryWarning.contains("Legendary")) + XCTAssertEqual(L(.en).freshEggDiscardValuable, "Send off") + } +}