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
8 changes: 8 additions & 0 deletions Sources/PokeTokenBar/Core/CompanionStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions Sources/PokeTokenBar/Core/Localization.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
12 changes: 6 additions & 6 deletions Sources/PokeTokenBar/UI/ShopView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)) }

Expand Down Expand Up @@ -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)
Expand Down
102 changes: 102 additions & 0 deletions Tests/PokeTokenBarTests/PreciousCompanionGuardTests.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading