From 48b0d792b32409bcad0f09d18ea927a76e2ad718 Mon Sep 17 00:00:00 2001 From: Seb1337 <16237592+Seb1337@users.noreply.github.com> Date: Thu, 3 Sep 2026 19:16:33 +0200 Subject: [PATCH 1/8] Add box/bank persistence model (MonState id + isComplete, CompanionState box + queuedEgg) --- .../PokeTokenBar/Core/CompanionModel.swift | 24 +++++++++++ Sources/PokeTokenBar/Core/SaveTransfer.swift | 4 +- Tests/PokeTokenBarTests/CompanionTests.swift | 40 +++++++++++++++++++ .../PokeTokenBarTests/SaveTransferTests.swift | 3 +- 4 files changed, 68 insertions(+), 3 deletions(-) diff --git a/Sources/PokeTokenBar/Core/CompanionModel.swift b/Sources/PokeTokenBar/Core/CompanionModel.swift index 5b23edda..642ccafd 100644 --- a/Sources/PokeTokenBar/Core/CompanionModel.swift +++ b/Sources/PokeTokenBar/Core/CompanionModel.swift @@ -391,6 +391,9 @@ enum PokemonOdds { /// 현재 키우는 포켓몬. struct MonState: Codable, Sendable { + // Stable per-individual id so the box can address/swap a specific Pokémon. + // Legacy saves have no id → the decoder assigns a fresh one. + var id: String = UUID().uuidString var baseID: Int var pathIDs: [Int] // 실제 진화 경로(분기 선택 반영) var plannedPathIDs: [Int] // 사전에 선택한 전체 진화 경로 @@ -403,6 +406,9 @@ struct MonState: Codable, Sendable { // 메타몽 위장 — nil=일반. 값=정체 메타몽, 이 종으로 위장 중(위장 구간엔 baseID 와 동일, 리빌 후에도 원 위장체 보존). var dittoDisguise: Int? var dittoRevealed = false // 위장 → 리빌(정체 공개) 전환 여부 + // Marks a fully-raised (graduated) individual kept in the box. Guards applyUsage from + // re-growing / re-graduating it when it is brought back as the active buddy. + var isComplete = false // pathIDs 가 비면(손상된 상태 파일) baseID 로 폴백 — 렌더마다 읽히므로 out-of-bounds 크래시 방지. var currentID: Int { pathIDs.isEmpty ? baseID : pathIDs[min(stageIndex, pathIDs.count - 1)] } @@ -429,6 +435,9 @@ struct MonState: Codable, Sendable { // 하위호환 디코딩: shiny/nature 는 구버전 저장에 없음 → 기본값. init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) + // Legacy saves predate the box → no id/isComplete. Assign a fresh id, default false. + id = try c.decodeIfPresent(String.self, forKey: .id) ?? UUID().uuidString + isComplete = try c.decodeIfPresent(Bool.self, forKey: .isComplete) ?? false baseID = try c.decode(Int.self, forKey: .baseID) pathIDs = try c.decode([Int].self, forKey: .pathIDs) // 빈 pathIDs 는 손상 상태 → 디코드 실패시켜 전체 CompanionState 가 기본(알)로 폴백되게 한다. @@ -528,6 +537,13 @@ private extension KeyedDecodingContainer { } } +/// A purchased egg parked while the active Pokémon keeps growing. Wrapping it in an optional +/// (`queuedEgg: QueuedEgg?`) distinguishes "no egg waiting" (nil) from "an egg with no rarity +/// guarantee waiting" (.some(tier: nil)) — a plain `Rarity?` could not tell those apart. +struct QueuedEgg: Codable, Sendable { + var tier: Rarity? +} + /// 영속 상태(Application Support JSON). 포켓몬 전환 — 이전 커스텀 캐릭터 상태는 폐기(새로 시작). struct CompanionState: Codable, Sendable { // 토큰: 설치 이후만 측정 @@ -559,6 +575,11 @@ struct CompanionState: Codable, Sendable { // 종 단위 선택이라 성격 같은 개체 정보는 들고 있지 않는다. 선택 가능한 범위는 도감과 동일하게 // 졸업분 + 현재 개체의 도달 단계이며, 그 범위에서 빠지면 reconcileRepresentativeSelection 이 nil 로 복구한다. var representativeSpeciesID: Int? = nil + // Box/bank: stored individuals — still-growing parked mons and completed buddies. + // Each keeps its full MonState, so token status (stageIndex, usedAtStage) survives verbatim. + var box: [MonState] = [] + // A purchased egg waiting to become active on the next graduation. nil = none waiting. + var queuedEgg: QueuedEgg? = nil // 도감 var dex: [DexEntry] = [] // 소유한 (base,final) 쌍 — 분기 다양성용 @@ -598,6 +619,9 @@ struct CompanionState: Codable, Sendable { // active 손상(빈 pathIDs 등) → 알로 폴백하되 도감·인벤토리는 보존. active = c.lenientOptional(MonState.self, forKey: .active) representativeSpeciesID = c.lenientOptional(Int.self, forKey: .representativeSpeciesID) + // Box is per-item isolated (Lossy wrapper) like dex — one corrupt entry won't drop the array. + box = c.lenient([Lossy].self, forKey: .box, default: []).compactMap(\.value) + queuedEgg = c.lenientOptional(QueuedEgg.self, forKey: .queuedEgg) // 도감은 항목별 격리 — 손상 항목 하나가 도감 전체를 날리지 않게. dex = c.lenient([Lossy].self, forKey: .dex, default: []).compactMap(\.value) collectedFinals = c.lenient(Set.self, forKey: .collectedFinals, default: []) diff --git a/Sources/PokeTokenBar/Core/SaveTransfer.swift b/Sources/PokeTokenBar/Core/SaveTransfer.swift index 518872a7..6deece25 100644 --- a/Sources/PokeTokenBar/Core/SaveTransfer.swift +++ b/Sources/PokeTokenBar/Core/SaveTransfer.swift @@ -173,8 +173,8 @@ enum SaveTransfer { /// 다른 기기에서 온 상태를 **이 기기 기준으로 재정렬**한다. /// /// `CompanionState` 의 필드는 이전 관점에서 세 부류다. - /// - **진행**: 어느 기기에서든 참(`usedSinceInstall`·`dex`·`inventory`·`active`·`eggUsage`·`eggTier`· - /// `representativeSpeciesID`…) + /// - **진행**: 어느 기기에서든 참(`usedSinceInstall`·`dex`·`inventory`·`active`·`box`·`queuedEgg`· + /// `eggUsage`·`eggTier`·`representativeSpeciesID`…) /// → 그대로. 알 보증(`eggTier`)은 산 물건이지 이 기기의 장부가 아니라 기기를 옮겨도 따라간다. /// - **로컬 장부**: *그 기기가* 어디까지 적립했나(`claimedTodayTokensByProvider`·`lastDate`·`installBaselineSet`) /// → 새 기기 기준으로 다시 잡는다. 그대로 들여오면 옛 기기의 오늘 총량이 문턱이 되어 diff --git a/Tests/PokeTokenBarTests/CompanionTests.swift b/Tests/PokeTokenBarTests/CompanionTests.swift index c85a3a58..ea8db932 100644 --- a/Tests/PokeTokenBarTests/CompanionTests.swift +++ b/Tests/PokeTokenBarTests/CompanionTests.swift @@ -1488,6 +1488,46 @@ final class CompanionIdentityTests: XCTestCase { XCTAssertEqual(round.active?.isShiny, false) } + /// [Box] A save predating the box decodes cleanly: empty box, no queued egg, and the active + /// mon gets a fresh id with isComplete=false (new fields default via lenient decode). + func testBoxFieldsBackwardCompatibleDecode() throws { + let old = """ + {"installBaselineSet":true,"usedSinceInstall":100,"eggUsage":0,"lastDate":"d1", + "active":{"baseID":1,"pathIDs":[1],"stageIndex":0,"usedAtStage":5,"rarity":"common","totalForms":3}, + "dex":[],"collectedFinals":[],"language":"ko"} + """ + let s = try JSONDecoder().decode(CompanionState.self, from: Data(old.utf8)) + XCTAssertTrue(s.box.isEmpty, "legacy save has no box → empty array") + XCTAssertNil(s.queuedEgg, "legacy save has no queued egg") + XCTAssertFalse(s.active?.id.isEmpty ?? true, "active mon gets a fresh non-empty id") + XCTAssertEqual(s.active?.isComplete, false, "isComplete defaults to false") + } + + /// [Box] A stored box round-trips verbatim: id, isComplete and the exact token status + /// (stageIndex, usedAtStage) survive, so a parked Pokémon resumes unchanged. + func testBoxRoundTripPreservesTokenStatus() throws { + var state = CompanionState() + let parked = MonState(baseID: 4, pathIDs: [4, 5], plannedPathIDs: [4, 5, 6], + stageIndex: 1, usedAtStage: 777, rarity: .rare, totalForms: 3, + isShiny: true, nature: .adamant) + var finished = MonState(baseID: 25, pathIDs: [25], stageIndex: 0, usedAtStage: 0, + rarity: .common, totalForms: 1) + finished.isComplete = true + state.box = [parked, finished] + state.queuedEgg = QueuedEgg(tier: .uncommon) + + let round = try JSONDecoder().decode(CompanionState.self, from: JSONEncoder().encode(state)) + XCTAssertEqual(round.box.count, 2) + XCTAssertEqual(round.box[0].id, parked.id, "id survives the round trip") + XCTAssertEqual(round.box[0].stageIndex, 1) + XCTAssertEqual(round.box[0].usedAtStage, 777, "exact token status preserved") + XCTAssertEqual(round.box[0].isShiny, true) + XCTAssertEqual(round.box[0].nature, .adamant) + XCTAssertEqual(round.box[0].isComplete, false) + XCTAssertEqual(round.box[1].isComplete, true, "completed buddy flag persists") + XCTAssertEqual(round.queuedEgg?.tier, .uncommon) + } + /// [출시 안전] 손상된 상태 파일: active.pathIDs 가 비면 그 active 만 nil(알)로 폴백하되 나머지 상태는 /// 보존한다(필드별 관대화). 깨진 active 를 살려두면 currentID out-of-bounds 위험이므로 nil 이어야 한다 /// (예전엔 전체 디코드를 throw 시켜 상태 전면 초기화 → 도감·인벤토리까지 유실됐다). diff --git a/Tests/PokeTokenBarTests/SaveTransferTests.swift b/Tests/PokeTokenBarTests/SaveTransferTests.swift index 40eb7f46..35ebfa3a 100644 --- a/Tests/PokeTokenBarTests/SaveTransferTests.swift +++ b/Tests/PokeTokenBarTests/SaveTransferTests.swift @@ -512,9 +512,10 @@ final class SaveTransferTests: XCTestCase { /// 딸려 들어간다(`language` 가 실제로 그랬다). 필드 목록을 테스트로 고정해 **분류를 강제**한다. func testEveryCompanionStateFieldIsClassifiedForTransfer() { // eggTier(알 등급 보증) = 진행 — 산 물건이지 이 기기의 장부가 아니라 기기를 옮겨도 따라간다. + // box(보관 개체)·queuedEgg(대기 중 산 알) = 진행 — 수집·구매물이라 기기를 옮겨도 따라간다. let progress: Set = ["usedSinceInstall", "spentTokens", "eggUsage", "eggTier", "pendingHatchID", "active", "representativeSpeciesID", "dex", - "collectedFinals", "inventory"] + "collectedFinals", "inventory", "box", "queuedEgg"] let deviceLedger: Set = ["installBaselineSet", "claimedTodayTokensByProvider", "lastDate"] let accountLedger: Set = ["candyGrantTier", "candyFeatureSeeded"] let devicePreference: Set = ["language"] From ef5f22bd4f92b32d28f71b985c9fc219969297a4 Mon Sep 17 00:00:00 2001 From: Seb1337 <16237592+Seb1337@users.noreply.github.com> Date: Thu, 3 Sep 2026 19:24:17 +0200 Subject: [PATCH 2/8] Add box swap + graduate-to-box (setActiveFromBox, isComplete guard, box on graduation) --- .../PokeTokenBar/Core/CompanionStore.swift | 33 +++++++++ Tests/PokeTokenBarTests/CompanionTests.swift | 73 +++++++++++++++++++ 2 files changed, 106 insertions(+) diff --git a/Sources/PokeTokenBar/Core/CompanionStore.swift b/Sources/PokeTokenBar/Core/CompanionStore.swift index 2b20a61a..fd6a2762 100644 --- a/Sources/PokeTokenBar/Core/CompanionStore.swift +++ b/Sources/PokeTokenBar/Core/CompanionStore.swift @@ -477,6 +477,8 @@ final class CompanionStore { /// 프로바이더별 ledger 는 이미 전진해 델타가 영구 유실된다. 진화 판정만 라인 로드 후로 미룬다. func applyUsage(_ delta: Int) { guard state.active != nil else { return } + // A completed buddy brought back from the box does not grow or re-graduate. + guard state.active?.isComplete != true else { return } state.active!.usedAtStage += delta guard let line = currentLine else { save(); return } var guardCount = 0 @@ -601,6 +603,11 @@ final class CompanionStore { justGraduated = name notifyCompanionEvent(l.notifGraduateTitle, l.notifGraduateBody(name)) eventUntil = clock().addingTimeInterval(6) + // Keep the completed individual in the box (with shiny/nature/full path) so it can be + // brought back as a buddy later. The permanent dex record above is unchanged. + var finished = a + finished.isComplete = true + state.box.append(finished) state.active = nil state.reconcileRepresentativeSelection() // 졸업 체인이 dex 로 옮겨져 선택은 정상적으로 유지된다 activeGeneration += 1 @@ -612,6 +619,32 @@ final class CompanionStore { Task { await self.ensureEggPrefetch() } } + // MARK: Box (bank — store individuals & swap the active one) + + /// Stored individuals, for the box UI. + var boxedPokemon: [MonState] { state.box } + + /// A box↔active swap needs a living active — switching is disabled during egg incubation. + var canSwitchActive: Bool { state.active != nil } + + /// Make a boxed Pokémon the active one; the current active moves into the box (a swap). + /// Growth status (stageIndex/usedAtStage) is preserved because the whole MonState is moved. + /// Not allowed while the active slot is an egg (nil) — avoids parking egg incubation state. + @discardableResult + func setActiveFromBox(id: String) -> Bool { + guard let active = state.active else { return false } + guard let idx = state.box.firstIndex(where: { $0.id == id }) else { return false } + let picked = state.box.remove(at: idx) + state.box.append(active) // the swap — previous active goes to the box + state.active = picked + currentLine = nil // loadCurrentLine reloads + normalizes the new active + activeGeneration += 1 + state.reconcileRepresentativeSelection() + save() + Task { await loadCurrentLine() } // reload line, normalize against current assets, re-eval usage + return true + } + // MARK: 인벤토리 / 이상한 사탕 var rareCandyCount: Int { itemCount(.rareCandy) } diff --git a/Tests/PokeTokenBarTests/CompanionTests.swift b/Tests/PokeTokenBarTests/CompanionTests.swift index ea8db932..644acdb1 100644 --- a/Tests/PokeTokenBarTests/CompanionTests.swift +++ b/Tests/PokeTokenBarTests/CompanionTests.swift @@ -183,6 +183,79 @@ final class CompanionStoreTests: XCTestCase { return CompanionStore(provider: StubProvider(value: line), clock: { fixedNow }, fileURL: url, rng: SeededRNG(seed: seed)) } + // MARK: Box (bank swap) + + /// [Box] Switching to a boxed Pokémon swaps it with the current active; both keep their exact + /// token status. The displaced active lands in the box. + func testSetActiveFromBoxSwapsAndPreservesTokenStatus() throws { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("poke-box-\(UUID().uuidString).json") + let json = """ + {"active":{"id":"A","baseID":1,"pathIDs":[1,2],"stageIndex":1,"usedAtStage":40,"rarity":"common","totalForms":3}, + "box":[{"id":"B","baseID":4,"pathIDs":[4,5],"stageIndex":1,"usedAtStage":250,"rarity":"rare","totalForms":3,"isShiny":true}]} + """ + try Data(json.utf8).write(to: url) + let s = CompanionStore(provider: StubProvider(value: linear3), clock: { fixedNow }, + fileURL: url, rng: SeededRNG(seed: 7)) + + XCTAssertEqual(s.state.active?.id, "A") + XCTAssertEqual(s.boxedPokemon.map(\.id), ["B"]) + + XCTAssertTrue(s.setActiveFromBox(id: "B")) + + // B is now active with its exact status; A moved to the box unchanged. + XCTAssertEqual(s.state.active?.id, "B") + XCTAssertEqual(s.state.active?.stageIndex, 1) + XCTAssertEqual(s.state.active?.usedAtStage, 250) + XCTAssertEqual(s.state.active?.isShiny, true) + XCTAssertEqual(s.boxedPokemon.map(\.id), ["A"]) + XCTAssertEqual(s.boxedPokemon.first?.usedAtStage, 40, "displaced active keeps its token status") + } + + /// [Box] Rejected when there is no living active (egg phase) or the id is unknown. + func testSetActiveFromBoxGuards() { + let s = store(linear3) // fresh install → active == nil (egg) + XCTAssertFalse(s.canSwitchActive) + XCTAssertFalse(s.setActiveFromBox(id: "nope"), "no living active → reject") + } + + /// [Box] Graduation keeps the completed individual in the box (isComplete) while still adding + /// the permanent dex record. + func testGraduationBoxesCompletedIndividual() async { + let s = store(noEvo) + base(s) + use(s, PokemonBalance.eggHatchThreshold) + await s.hatchIfNeeded() + XCTAssertNotNil(s.state.active) + let dexBefore = s.state.dex.count + + s.applyUsage(PokemonBalance.graduationTotal(.common)) // single-form graduation + + XCTAssertNil(s.state.active, "graduated → back to egg") + XCTAssertEqual(s.state.box.count, 1, "completed individual kept in the box") + XCTAssertEqual(s.state.box.first?.isComplete, true) + XCTAssertEqual(s.state.dex.count, dexBefore + 1, "permanent dex record still added") + } + + /// [Box] A completed buddy re-activated from the box does not grow or re-graduate. + func testCompletedBuddyDoesNotRegrow() throws { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("poke-buddy-\(UUID().uuidString).json") + let json = """ + {"active":{"id":"A","baseID":1,"pathIDs":[1],"stageIndex":0,"usedAtStage":0,"rarity":"common","totalForms":1}, + "box":[{"id":"DONE","baseID":25,"pathIDs":[25],"stageIndex":0,"usedAtStage":0,"rarity":"common","totalForms":1,"isComplete":true}]} + """ + try Data(json.utf8).write(to: url) + let s = CompanionStore(provider: StubProvider(value: linear3), clock: { fixedNow }, + fileURL: url, rng: SeededRNG(seed: 7)) + XCTAssertTrue(s.setActiveFromBox(id: "DONE")) + XCTAssertEqual(s.state.active?.id, "DONE") + + s.applyUsage(PokemonBalance.graduationTotal(.common) * 2) // would graduate a normal mon + + XCTAssertEqual(s.state.active?.id, "DONE", "completed buddy stays active") + XCTAssertEqual(s.state.active?.usedAtStage, 0, "no growth accrued") + XCTAssertEqual(s.state.box.count, 1, "no re-graduation into the box") + } + // MARK: 상태 파일 decode 복원력 (회귀) /// [회귀] 도감 항목 하나가 손상돼도(구버전/필드 누락) 나머지 도감·companion·인벤토리를 지킨다 — From 5631341cfc5ce68a9bb264151138f2a8b70eb8da Mon Sep 17 00:00:00 2001 From: Seb1337 <16237592+Seb1337@users.noreply.github.com> Date: Thu, 3 Sep 2026 19:40:36 +0200 Subject: [PATCH 3/8] =?UTF-8?q?Add=20Box=20section=20to=20Collection=20(gr?= =?UTF-8?q?ouped=20Pok=C3=A9dex/Box=20segments,=20catch=20log=20set=20apar?= =?UTF-8?q?t)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/PokeTokenBar/Core/Localization.swift | 7 + Sources/PokeTokenBar/UI/CompanionView.swift | 193 +++++++++++++++++- Sources/PokeTokenBar/UI/PopoverView.swift | 8 +- .../PopoverNavigationTests.swift | 10 +- 4 files changed, 203 insertions(+), 15 deletions(-) diff --git a/Sources/PokeTokenBar/Core/Localization.swift b/Sources/PokeTokenBar/Core/Localization.swift index b29ba009..3d4e9a57 100644 --- a/Sources/PokeTokenBar/Core/Localization.swift +++ b/Sources/PokeTokenBar/Core/Localization.swift @@ -585,6 +585,13 @@ struct L { } var dexPagePrev: String { t("이전 페이지", "Previous page", "前のページ", "Página anterior", "Page précédente", "Página anterior", "Vorherige Seite") } var dexPageNext: String { t("다음 페이지", "Next page", "次のページ", "Página siguiente", "Page suivante", "Próxima página", "Nächste Seite") } + // MARK: Box (bank) + var boxTitle: String { t("박스", "Box", "ボックス", "Caja", "Boîte", "Caixa", "Box") } + var boxEmptyTitle: String { t("박스가 비어 있어요", "Your box is empty", "ボックスは空です", "Tu caja está vacía", "Ta boîte est vide", "Sua caixa está vazia", "Deine Box ist leer") } + var boxEmptyHint: String { t("졸업했거나 보관해 둔 포켓몬이 여기 모여요.", "Completed and parked Pokémon collect here.", "卒業した・預けたポケモンがここに集まります。", "Aquí se reúnen los Pokémon completados y guardados.", "Les Pokémon terminés et mis de côté s'accumulent ici.", "Pokémon concluídos e guardados se reúnem aqui.", "Fertige und geparkte Pokémon sammeln sich hier.") } + var boxSetActive: String { t("활성 포켓몬으로", "Make active", "アクティブにする", "Hacer activo", "Rendre actif", "Tornar ativo", "Als aktives Pokémon") } + var boxSwitchConfirm: String { t("교체", "Switch", "交代", "Cambiar", "Échanger", "Trocar", "Übernehmen") } + var boxSwitchDisabledHint: String { t("지금은 알을 품는 중이라 교체할 수 없어요.", "Can't switch while an egg is incubating.", "タマゴを育てている間は交代できません。", "No puedes cambiar mientras incuba un huevo.", "Impossible d'échanger pendant l'incubation d'un œuf.", "Não dá para trocar enquanto um ovo choca.", "Während ein Ei ausgebrütet wird, ist kein Wechsel möglich.") } var dexRaising: String { t("키우는 중", "Raising", "育成中", "Criando", "En élevage", "Treinando", "In Aufzucht") } /// 포획 로그에서 졸업분과 놓아준 개체를 가르는 표식. 종은 도감에 남고 개체 기록만 이 뱃지를 단다. var dexReleased: String { t("놓아줌", "Released", "逃がした", "Liberado", "Relâché", "Solto", "Freigelassen") } diff --git a/Sources/PokeTokenBar/UI/CompanionView.swift b/Sources/PokeTokenBar/UI/CompanionView.swift index d576eba4..a9783b44 100644 --- a/Sources/PokeTokenBar/UI/CompanionView.swift +++ b/Sources/PokeTokenBar/UI/CompanionView.swift @@ -755,17 +755,33 @@ struct CollectionView: View { var body: some View { @Bindable var nav = navigation - if store.dexEntries.isEmpty { - emptyState // 둘 다 비어 있으니 세그먼트를 그리지 않는다 + if store.dexEntries.isEmpty && store.boxedPokemon.isEmpty { + emptyState // 셋 다 비어 있으니 세그먼트를 그리지 않는다 } else { VStack(alignment: .leading, spacing: 8) { - Picker("", selection: $nav.showingCollectionLog) { - Text(store.l.dexTitle).tag(false) - Text(store.l.catchLogTitle).tag(true) + HStack(spacing: 8) { + // 도감·박스는 "보유물 둘러보기"라 왼쪽에 묶는다. + Picker("", selection: $nav.collectionSection) { + Text(store.l.dexTitle).tag(CollectionSection.dex) + Text(store.l.boxTitle).tag(CollectionSection.box) + } + .pickerStyle(.segmented) + .labelsHidden() + .fixedSize() + Spacer(minLength: 6) + // 포획 로그는 성격이 달라 오른쪽으로 떼어 놓는다(별도 토글). + Button { nav.collectionSection = .log } label: { + Text(store.l.catchLogTitle) + } + .buttonStyle(.bordered) + .controlSize(.small) + .tint(nav.collectionSection == .log ? Color.accentColor : nil) + } + switch nav.collectionSection { + case .dex: DexGridView(store: store) + case .box: BoxGridView(store: store) + case .log: catchLog } - .pickerStyle(.segmented) - .labelsHidden() - if nav.showingCollectionLog { catchLog } else { DexGridView(store: store) } } .frame(height: Self.contentHeight) } @@ -814,6 +830,167 @@ struct CollectionView: View { } } +/// 박스 격자 — 보관 중인 개체(성장 중·완료)를 도감과 같은 4열 페이지 격자로 보여준다. +/// 개체 단위라 종이 아닌 MonState.id 로 고르고, 선택 후 인라인 확인(버튼 모프)으로 활성과 교체한다 +/// (시트 없음 — 프로젝트 규칙). 알을 품는 중(active == nil)엔 교체를 막고 이유를 안내한다. +@MainActor +private struct BoxGridView: View { + let store: CompanionStore + @State private var selectedID: String? + @State private var page = 0 + @State private var confirming = false + + private static let columns = 4 + private static let rows = 6 + private static let pageSize = columns * rows // 24 + private static let spacing: CGFloat = 4 + + var body: some View { + let mons = store.boxedPokemon + if mons.isEmpty { + emptyState + } else { + let pageCount = max(1, (mons.count + Self.pageSize - 1) / Self.pageSize) + let current = min(page, pageCount - 1) + let slice = Array(mons.dropFirst(current * Self.pageSize).prefix(Self.pageSize)) + VStack(alignment: .leading, spacing: 8) { + grid(slice) + footer(current: current, pageCount: pageCount) + } + } + } + + private var emptyState: some View { + VStack(spacing: 10) { + SpriteView(speciesID: 143, size: 96, animated: true) // 잠자는 마스코트(가방과 같은 톤) + Text(store.l.boxEmptyTitle).font(.callout.weight(.semibold)) + Text(store.l.boxEmptyHint) + .font(.caption).foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.vertical, 28) + } + + private func grid(_ slice: [MonState]) -> some View { + VStack(spacing: Self.spacing) { + ForEach(0.. some View { + HStack(spacing: 8) { + action + Spacer(minLength: 4) + if pageCount > 1 { + Button { page = max(0, current - 1); reset() } label: { + Image(systemName: "chevron.left") + } + .buttonStyle(.plain).disabled(current == 0) + .accessibilityLabel(store.l.dexPagePrev) + Text("\(current + 1) / \(pageCount)") + .font(.system(size: 10, weight: .semibold)).monospacedDigit() + .foregroundStyle(.secondary) + .accessibilityLabel(store.l.dexPageLabel(current + 1, pageCount)) + Button { page = min(pageCount - 1, current + 1); reset() } label: { + Image(systemName: "chevron.right") + } + .buttonStyle(.plain).disabled(current == pageCount - 1) + .accessibilityLabel(store.l.dexPageNext) + } + } + .font(.system(size: 11, weight: .semibold)) + .frame(height: 22) + } + + /// 선택한 개체를 활성으로 만드는 인라인 액션. 알 품는 중엔 버튼 대신 이유를 띄운다. + @ViewBuilder + private var action: some View { + if let id = selectedID { + if !store.canSwitchActive { + Text(store.l.boxSwitchDisabledHint) + .font(.system(size: 9)).foregroundStyle(.secondary).lineLimit(2) + } else if confirming { + Button(store.l.cancel) { confirming = false } + .buttonStyle(.bordered).controlSize(.mini) + Button(store.l.boxSwitchConfirm) { + _ = store.setActiveFromBox(id: id) + reset() + } + .buttonStyle(.borderedProminent).controlSize(.mini) + } else { + Button(store.l.boxSetActive) { confirming = true } + .buttonStyle(.bordered).controlSize(.mini) + } + } + } + + private func reset() { + selectedID = nil + confirming = false + } +} + +/// 박스 한 칸 — 스프라이트 + 도감 번호 + (완료 뱃지 또는 현재 형태 진행 바). 종 이름은 개체마다 +/// 라인 조회가 필요해 여기서는 번호만 쓴다(도감 칸이 이름 없을 때 #번호로 두는 태도와 동일). +@MainActor +private struct BoxMonCell: View { + let mon: MonState + let isSelected: Bool + let onTap: () -> Void + + private var progress: Double { + let thr = PokemonBalance.phaseThreshold(rarity: mon.rarity, totalForms: mon.totalForms, + stageIndex: mon.stageIndex) + guard thr > 0 else { return 0 } + return min(1, Double(mon.usedAtStage) / Double(thr)) + } + + var body: some View { + Button(action: onTap) { + VStack(spacing: 3) { + SpriteView(speciesID: mon.currentID, size: 40, shiny: mon.isShiny) + Text("#\(mon.currentID)") + .font(.system(size: 10, weight: .semibold)).monospacedDigit() + .lineLimit(1) + if mon.isComplete { + Image(systemName: "checkmark.seal.fill") + .font(.system(size: 9)).foregroundStyle(.green) + } else { + ProgressView(value: progress) + .progressViewStyle(.linear) + .frame(height: 3).padding(.horizontal, 3) + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 6).padding(.horizontal, 3) + .background(RoundedRectangle(cornerRadius: 9) + .fill(Color.primary.opacity(isSelected ? 0.10 : 0.04))) + .overlay(RoundedRectangle(cornerRadius: 9) + .strokeBorder(isSelected ? Color.accentColor : Color.clear, lineWidth: 1.5)) + } + .buttonStyle(.plain) + } +} + /// 도감 하단의 대표 설정 액션. 문구는 툴팁·접근성에 유지하되 시각적으로는 아이콘만 써서, /// 긴 en/es 문구가 선택한 종의 이름·희귀도를 밀어내지 않게 한다. @MainActor diff --git a/Sources/PokeTokenBar/UI/PopoverView.swift b/Sources/PokeTokenBar/UI/PopoverView.swift index 9bac10a0..a5f83323 100644 --- a/Sources/PokeTokenBar/UI/PopoverView.swift +++ b/Sources/PokeTokenBar/UI/PopoverView.swift @@ -3,6 +3,10 @@ import SwiftUI enum PopoverTab { case home, shop, bag, collection } +/// Collection 내부 세그먼트 — 도감(보유 종)·박스(보관 개체)·포획 로그(개체 기록). +/// 도감과 박스는 "보유물 둘러보기"라 왼쪽에 묶고, 성격이 다른 포획 로그는 오른쪽으로 떼어 놓는다. +enum CollectionSection { case dex, box, log } + /// 팝오버 치수의 단일 소스. 자식이 쓸 수 있는 폭을 알아야 할 때 이 값을 쓴다 — 넘치는 자식이 /// 부모 폭을 부풀리므로 GeometryReader 로 재면 순환한다. enum PopoverMetrics { @@ -21,7 +25,7 @@ final class PopoverNavigation { var showSettings = false var tab: PopoverTab = .home /// 일반적인 컬렉션 재진입에는 마지막 세그먼트를 유지하되, 대표 포켓몬 선택 진입점은 도감으로 강제한다. - var showingCollectionLog = false + var collectionSection: CollectionSection = .dex /// 프로바이더 탭 선택 — reset() 대상이 아님(팝오버를 다시 열어도 보던 서비스 유지). var providerID: String? /// 설정을 열 때 고급 섹션을 펼친 채로 시작할지. 세션 키 행이 접힌 disclosure 안에 살아서, @@ -44,7 +48,7 @@ final class PopoverNavigation { /// 컬렉션 세그먼트를 도감으로 명시해, 직전에 포획 로그를 봤어도 선택 액션이 있는 화면을 연다. func openRepresentativeDex() { showSettings = false - showingCollectionLog = false + collectionSection = .dex tab = .collection } } diff --git a/Tests/PokeTokenBarTests/PopoverNavigationTests.swift b/Tests/PokeTokenBarTests/PopoverNavigationTests.swift index 9c2541da..c7205b5a 100644 --- a/Tests/PokeTokenBarTests/PopoverNavigationTests.swift +++ b/Tests/PokeTokenBarTests/PopoverNavigationTests.swift @@ -9,30 +9,30 @@ final class PopoverNavigationTests: XCTestCase { let nav = PopoverNavigation() XCTAssertFalse(nav.showSettings) XCTAssertEqual(nav.tab, .home) - XCTAssertFalse(nav.showingCollectionLog) + XCTAssertEqual(nav.collectionSection, .dex) } func testResetReturnsToHomeFromSettings() { let nav = PopoverNavigation() nav.showSettings = true nav.tab = .collection - nav.showingCollectionLog = true + nav.collectionSection = .log nav.reset() XCTAssertFalse(nav.showSettings) // 설정 화면 닫힘 XCTAssertEqual(nav.tab, .home) // 탭도 Home 으로 - XCTAssertTrue(nav.showingCollectionLog, "일반 재진입은 사용자가 보던 컬렉션 세그먼트를 유지") + XCTAssertEqual(nav.collectionSection, .log, "일반 재진입은 사용자가 보던 컬렉션 세그먼트를 유지") } func testOpenRepresentativeDexLeavesSettingsForCollection() { let nav = PopoverNavigation() nav.showSettings = true - nav.showingCollectionLog = true + nav.collectionSection = .log nav.openRepresentativeDex() XCTAssertFalse(nav.showSettings) XCTAssertEqual(nav.tab, .collection) - XCTAssertFalse(nav.showingCollectionLog, "포획 로그에서 설정을 열었어도 대표 선택은 도감으로 이동") + XCTAssertEqual(nav.collectionSection, .dex, "포획 로그에서 설정을 열었어도 대표 선택은 도감으로 이동") } } From fbb587defadd4e277abeaecab765658636ddb39b Mon Sep 17 00:00:00 2001 From: Seb1337 <16237592+Seb1337@users.noreply.github.com> Date: Thu, 3 Sep 2026 20:14:48 +0200 Subject: [PATCH 4/8] Egg purchase queues instead of releasing the active --- .../PokeTokenBar/Core/CompanionModel.swift | 4 + .../PokeTokenBar/Core/CompanionStore.swift | 109 ++++++++---------- Sources/PokeTokenBar/Core/Localization.swift | 50 +++++--- Sources/PokeTokenBar/UI/CompanionView.swift | 31 ++++- Sources/PokeTokenBar/UI/ShopView.swift | 39 +++---- Tests/PokeTokenBarTests/CompanionTests.swift | 84 +++++++++----- Tests/PokeTokenBarTests/FreshEggTests.swift | 101 ++++++++-------- Tests/PokeTokenBarTests/PremiumEggTests.swift | 30 +++-- 8 files changed, 253 insertions(+), 195 deletions(-) diff --git a/Sources/PokeTokenBar/Core/CompanionModel.swift b/Sources/PokeTokenBar/Core/CompanionModel.swift index 642ccafd..54040f26 100644 --- a/Sources/PokeTokenBar/Core/CompanionModel.swift +++ b/Sources/PokeTokenBar/Core/CompanionModel.swift @@ -635,6 +635,8 @@ struct CompanionState: Codable, Sendable { /// 도감 전체 표시 모델을 만들지 않고 대표 종 하나만 확인하는 경량 경로다. func ownsSpecies(_ speciesID: Int) -> Bool { if dex.contains(where: { $0.chainOrder.contains(speciesID) }) { return true } + // Boxed individuals count as owned — their reached stages stay in the collection. + if box.contains(where: { $0.pathIDs.prefix($0.stageIndex + 1).contains(speciesID) }) { return true } guard let active else { return false } return active.pathIDs.prefix(active.stageIndex + 1).contains(speciesID) } @@ -643,6 +645,8 @@ struct CompanionState: Codable, Sendable { /// 도감 표시 모델은 계산하지 않는다. 위장 중인 메타몽의 이로치는 리빌 전까지 숨긴다. func ownsShinySpecies(_ speciesID: Int) -> Bool { if dex.contains(where: { $0.isShiny && $0.chainOrder.contains(speciesID) }) { return true } + // A boxed shiny counts (no active-only ditto-disguise hiding applies to stored individuals). + if box.contains(where: { $0.isShiny && $0.pathIDs.prefix($0.stageIndex + 1).contains(speciesID) }) { return true } guard let active, active.pathIDs.prefix(active.stageIndex + 1).contains(speciesID), active.isShiny else { return false } diff --git a/Sources/PokeTokenBar/Core/CompanionStore.swift b/Sources/PokeTokenBar/Core/CompanionStore.swift index fd6a2762..144d9a26 100644 --- a/Sources/PokeTokenBar/Core/CompanionStore.swift +++ b/Sources/PokeTokenBar/Core/CompanionStore.swift @@ -194,34 +194,6 @@ final class CompanionStore { ) } - /// 놓아준 개체의 영구 기록 — 알을 새로 사서 육성을 포기하는 순간 만든다. - /// - /// **도달한 형태만 담는다**(`pathIDs.prefix(stageIndex + 1)`). 도감이 육성 중 보여주던 범위와 - /// 같아야 놓아준 뒤에도 칸 구성이 그대로 유지된다 — `plannedPathIDs` 나 `pathIDs` 전체를 쓰면 - /// 도달한 적 없는 진화형까지 보유로 잡힌다(`dexSpecies` 가 같은 prefix 규칙을 쓴다). - /// - /// 이로치는 `currentIsShiny` — 위장 중인 메타몽은 리빌 전까지 숨긴다(`activeDexEntry` 와 단일 판정). - /// `caughtAt` 은 놓아준 시각이다: 포획 로그가 그 값으로 정렬하므로 기록이 남은 시점과 일치해야 한다. - private func releasedDexEntry(from a: MonState) -> DexEntry { - // stageIndex 가 음수·범위 밖이어도 최소 한 형태는 남긴다(손상 상태 파일 방어 — MonState.currentID 와 같은 태도). - let reached = Array(a.pathIDs.prefix(max(1, a.stageIndex + 1))) - let chain = reached.isEmpty ? [a.baseID] : reached - let now = clock() - return DexEntry( - baseID: a.baseID, - finalID: chain.last ?? a.baseID, - chainOrder: chain, - rarity: a.rarity, - caughtAt: now, - isShiny: currentIsShiny, - nature: a.nature, - names: currentLine.map { line in - Dictionary(uniqueKeysWithValues: - chain.compactMap { id in line.names[id].map { (id, $0) } }) - }, - releasedAt: now) - } - var dexEntries: [DexEntry] { guard let activeDexEntry else { return state.dex } return state.dex + [activeDexEntry] @@ -297,6 +269,15 @@ final class CompanionStore { acc[id] = a } } + // Boxed individuals count as owned too — their reached stages (incl. pre-evolutions) stay in + // the Pokédex, so switching the active companion never removes species from the collection. + for mon in state.box { + for id in mon.pathIDs.prefix(mon.stageIndex + 1) { + var a = acc[id] ?? DexAccumulator(rarity: mon.rarity) + if mon.isShiny { a.isShiny = true } + acc[id] = a + } + } return acc.sorted { $0.key < $1.key }.map { id, a in DexSpecies( id: id, @@ -613,8 +594,12 @@ final class CompanionStore { activeGeneration += 1 currentLine = nil state.eggUsage = 0 // 새 알은 처음부터 인큐베이션 - // eggTier 는 손대지 않는다 — 여기 도달했다는 건 활성 포켓몬이 있었다는 뜻이라 보증은 이미 nil 이다 - // (부화가 소비, 디스크/불러오기는 sanitized 가 정규화). 소비 지점은 hatchCore 한 곳으로 유지한다. + // A purchased egg that was waiting behind this companion now moves into the freshly emptied + // egg slot (its guarantee, if any). With no queued egg the next egg is a plain, unguaranteed + // one (eggTier = nil) — reaching here means there was an active companion, so any prior + // guarantee was already consumed at its own hatch. + state.eggTier = state.queuedEgg?.tier + state.queuedEgg = nil // "알을 받는 순간" 즉시 프리패칭 시작 — 다음 부화의 종·라인·스프라이트 예열. Task { await self.ensureEggPrefetch() } } @@ -645,6 +630,19 @@ final class CompanionStore { return true } + /// Localized current-form names for boxed Pokémon (mon.id → name). Lines are cached per base by + /// PokeAPIClient, so this is usually network-free after the first look; offline/missing lines are + /// omitted and the cell falls back to the Pokédex number. + func boxDisplayNames() async -> [String: String] { + var out: [String: String] = [:] + for mon in state.box { + if let line = try? await provider.line(baseSpeciesID: mon.baseID) { + out[mon.id] = line.localizedName(mon.currentID, state.language) + } + } + return out + } + // MARK: 인벤토리 / 이상한 사탕 var rareCandyCount: Int { itemCount(.rareCandy) } @@ -782,51 +780,36 @@ final class CompanionStore { /// 현재 알이 보증하는 등급 하한(UI 표시용). 활성 포켓몬이 있으면 알이 없으므로 nil. var eggGuarantee: Rarity? { state.active == nil ? state.eggTier : nil } - /// 알 구매 가능 — 폐기할 활성 포켓몬이 있고 지갑이 그 티어 가격 이상일 때만. - /// 알 상태에서도 살 수 있게 하는 안은 채택하지 않았다(기존 새 알과 게이트 통일) — 알끼리 교체하는 - /// 동작을 새로 만들지 않고, 상점의 알은 언제나 "지금 개체를 놓아주고 다시 뽑는다"는 한 가지 의미만 갖는다. - /// 항목 자체는 알 상태에서도 상점에 남는다(shopEntries) — 이 게이트는 구매만 막는다. + /// Buyable only when a living companion is growing (the purchased egg waits behind it) and no + /// egg is already queued — one waiting egg at a time. The sellable-tier guard stays: an + /// unsatisfiable guarantee (legendary) would take tokens for an egg that can never hatch. + /// The shop still lists the entry during egg incubation; this gate only blocks the purchase. func canBuyEgg(_ tier: Rarity?) -> Bool { - // 파는 티어인지 먼저 확인한다 — 만족 불가능한 보증(전설: capture_rate 로 표현 불가)을 사면 - // 두 롤 경로 모두 후보가 0개라 알이 영영 안 깨지고, 부화가 없으니 보증도 안 풀리며, - // 새 알 구매는 `hasActive` 에 막혀 되돌릴 수단이 없다. 가격만 계산되면 값이 빠져나가므로 - // 판매 목록을 여기서 강제한다(호출부 하나가 실수하면 토큰이 통째로 사라진다). guard FreshEgg.shopTiers.contains(tier) else { return false } - return hasActive && availableTokens >= FreshEgg.price(guaranteeing: tier) + return hasActive && state.queuedEgg == nil + && availableTokens >= FreshEgg.price(guaranteeing: tier) } - /// 알 구매 — 현재 포켓몬을 놓아주고 처음부터 인큐베이션하는 새 알로. 지갑에서 가격 차감. - /// graduate() 의 알-리셋을 미러링하되, 놓아준 개체는 **도감에 남긴다**(`releasedDexEntry`). - /// 도감은 "쌓이기만 한다"는 약속을 주는데, 여기가 종이 사라질 수 있던 유일한 경로였다. - /// `collectedFinals`(최종체 완성·분기 가중)는 여전히 손대지 않는다 — 끝까지 키운 게 아니다. - /// 성장(usedAtStage)은 소멸(추가 비용). - /// - /// 여기서 종을 롤하지 않는다 — 롤에는 네트워크가 필요해서 오프라인이면 토큰만 사라진다. 보증만 - /// 상태(`eggTier`)에 적고, 실제 롤은 프리패치/부화 경로가 그 보증을 읽어 수행한다. + /// Buy an egg — the current companion is NOT released or discarded. It keeps growing; the egg + /// waits (`queuedEgg`) and becomes active at the next graduation (see `graduate()`). The wallet + /// is charged now and the rarity guarantee (if any) is stored on the queued egg; the species is + /// rolled later at hatch, so an offline purchase never loses tokens to a failed roll. @discardableResult func buyEgg(_ tier: Rarity?) -> Bool { guard canBuyEgg(tier) else { return false } state.spentTokens += FreshEgg.price(guaranteeing: tier) - if let a = state.active { - state.dex.append(releasedDexEntry(from: a)) // 놓아줌 기록 — 도감에서 종이 사라지지 않게 - } - state.active = nil // 놓아줌 (졸업 아님 — collectedFinals 는 미변경) - // 놓아준 종도 이제 dex 에 있으므로 대표 선택은 유지된다. 손상 상태 파일 등으로 정말 보유가 - // 끊긴 경우만 자동 추적으로 복귀한다. - state.reconcileRepresentativeSelection() - activeGeneration += 1 - currentLine = nil - state.eggUsage = 0 // 새 알은 처음부터 인큐베이션(재부화에 5M 필요) - state.eggTier = tier // 등급 보증(nil = 보증 없음) - state.pendingHatchID = nil // 새 보증으로 처음부터 롤(활성 포켓몬이 있는 동안엔 원래 비어 있다) - prefetchedLineID = nil - justGraduated = nil; justEvolvedTo = nil; eventUntil = nil - AppLog.write("egg purchased: discarded active, tier=\(tier?.rawValue ?? "none")") - Task { await self.ensureEggPrefetch() } // 다음 부화 예열 + state.queuedEgg = QueuedEgg(tier: tier) + AppLog.write("egg queued: tier=\(tier?.rawValue ?? "none")") save() return true } + /// A purchased egg waiting behind the active companion. Surfaced to the UI so the box can show a + /// "waiting egg" card and the shop can reflect that another can't be queued yet. `queuedEggTier` + /// is only meaningful when `hasQueuedEgg` is true (nil there means "no rarity guarantee"). + var hasQueuedEgg: Bool { state.queuedEgg != nil } + var queuedEggTier: Rarity? { state.queuedEgg?.tier } + // 보증 없는 기본 알 래퍼 — 기존 호출부/테스트 호환. var canBuyFreshEgg: Bool { canBuyEgg(nil) } @discardableResult diff --git a/Sources/PokeTokenBar/Core/Localization.swift b/Sources/PokeTokenBar/Core/Localization.swift index 3d4e9a57..f0db469e 100644 --- a/Sources/PokeTokenBar/Core/Localization.swift +++ b/Sources/PokeTokenBar/Core/Localization.swift @@ -592,6 +592,8 @@ struct L { var boxSetActive: String { t("활성 포켓몬으로", "Make active", "アクティブにする", "Hacer activo", "Rendre actif", "Tornar ativo", "Als aktives Pokémon") } var boxSwitchConfirm: String { t("교체", "Switch", "交代", "Cambiar", "Échanger", "Trocar", "Übernehmen") } var boxSwitchDisabledHint: String { t("지금은 알을 품는 중이라 교체할 수 없어요.", "Can't switch while an egg is incubating.", "タマゴを育てている間は交代できません。", "No puedes cambiar mientras incuba un huevo.", "Impossible d'échanger pendant l'incubation d'un œuf.", "Não dá para trocar enquanto um ovo choca.", "Während ein Ei ausgebrütet wird, ist kein Wechsel möglich.") } + var boxWaitingEgg: String { t("대기 중인 알", "Waiting egg", "待機中のタマゴ", "Huevo en espera", "Œuf en attente", "Ovo à espera", "Wartendes Ei") } + var boxWaitingEggHint: String { t("지금 포켓몬이 졸업하면 부화를 시작해요.", "Starts hatching after your active Pokémon graduates.", "今のポケモンが卒業すると孵り始めます。", "Empieza a eclosionar cuando tu Pokémon activo se gradúa.", "Éclot après la remise de diplôme de ton Pokémon actif.", "Começa a chocar quando seu Pokémon ativo se forma.", "Beginnt zu schlüpfen, sobald dein aktives Pokémon seinen Abschluss macht.") } var dexRaising: String { t("키우는 중", "Raising", "育成中", "Criando", "En élevage", "Treinando", "In Aufzucht") } /// 포획 로그에서 졸업분과 놓아준 개체를 가르는 표식. 종은 도감에 남고 개체 기록만 이 뱃지를 단다. var dexReleased: String { t("놓아줌", "Released", "逃がした", "Liberado", "Relâché", "Solto", "Freigelassen") } @@ -844,22 +846,22 @@ struct L { } func eggDescription(_ tier: Rarity?) -> String { guard let tier, tier != .common else { - return t("지금 포켓몬을 놓아주고 새 알로 다시 시작해요.", - "Send off your current Pokémon and start fresh with a new egg.", - "いまのポケモンを手放して新しいタマゴからやり直します。", - "Suelta a tu Pokémon actual y empieza de nuevo con un huevo nuevo.", - "Laisse partir ton Pokémon actuel et repars de zéro avec un nouvel œuf.", - "Solte seu Pokémon atual e recomece com um ovo novo.", - "Verabschiede dein aktuelles Pokémon und starte mit einem neuen Ei.") + return t("네 포켓몬은 그대로 크고, 이 알은 졸업 뒤에 부화를 시작해요.", + "Your Pokémon keeps growing; this egg starts hatching after it graduates.", + "今のポケモンはそのまま育ち、このタマゴは卒業後に孵り始めます。", + "Tu Pokémon sigue creciendo; este huevo empieza a eclosionar tras su graduación.", + "Ton Pokémon continue de grandir ; cet œuf éclot après sa remise de diplôme.", + "Seu Pokémon continua crescendo; este ovo começa a chocar após a formatura.", + "Dein Pokémon wächst weiter; dieses Ei beginnt nach dessen Abschluss zu schlüpfen.") } let r = rarityLabel(tier) - return t("지금 포켓몬을 놓아주고 \(r) 이상이 확정으로 나오는 알을 받아요.", - "Send off your current Pokémon for an egg guaranteed to hatch \(r) or better.", - "いまのポケモンを手放して \(r) 以上が確定で孵るタマゴをもらいます。", - "Suelta a tu Pokémon actual y consigue un huevo garantizado de \(r) o superior.", - "Laisse partir ton Pokémon actuel pour un œuf garanti \(r) ou mieux.", - "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.") + return t("네 포켓몬은 그대로 크고, 졸업 뒤에 \(r) 이상 확정 알이 부화를 시작해요.", + "Your Pokémon keeps growing; after it graduates, an egg guaranteed \(r) or better starts hatching.", + "今のポケモンはそのまま育ち、卒業後に \(r) 以上確定のタマゴが孵り始めます。", + "Tu Pokémon sigue creciendo; tras su graduación eclosiona un huevo garantizado de \(r) o superior.", + "Ton Pokémon continue de grandir ; après sa remise de diplôme éclot un œuf garanti \(r) ou mieux.", + "Seu Pokémon continua crescendo; após a formatura começa a chocar um ovo garantido de \(r) ou melhor.", + "Dein Pokémon wächst weiter; nach dessen Abschluss beginnt ein Ei mit Garantie \(r) oder besser zu schlüpfen.") } /// 알 상태의 상점 알 카드 비활성 사유 — 항목은 보이되 구매 버튼 아래에 한 줄로 붙는다(EggCard). var eggShopLockedHint: String { @@ -885,6 +887,26 @@ struct L { "Soltar \(monName) e trocar pelo \(eggName)?", "\(monName) verabschieden und gegen \(eggName) tauschen?") } + /// EggCard 구매 확인 — 놓아주는 게 아니라 "예약해 두고 졸업 때 부화"하는 새 의미. + func eggQueueConfirm(_ eggName: String) -> String { + t("\(eggName)을(를) 예약할까요? 지금 포켓몬이 졸업하면 부화를 시작해요.", + "Queue the \(eggName)? It starts hatching after your current Pokémon graduates.", + "\(eggName) を予約しますか?今のポケモンが卒業すると孵り始めます。", + "¿Reservar el \(eggName)? Empezará a eclosionar cuando tu Pokémon actual se gradúe.", + "Réserver le \(eggName) ? Il éclora après la remise de diplôme de ton Pokémon actuel.", + "Reservar o \(eggName)? Ele começa a chocar quando seu Pokémon atual se formar.", + "Das \(eggName) vormerken? Es schlüpft, sobald dein aktuelles Pokémon seinen Abschluss macht.") + } + /// 이미 예약된 알이 있을 때 상점 카드 하단 안내 — 한 번에 하나만 대기한다. + var eggQueuedHint: String { + t("이미 알 하나가 대기 중이에요.", + "An egg is already waiting.", + "すでにタマゴが1つ待機中です。", + "Ya hay un huevo esperando.", + "Un œuf attend déjà.", + "Um ovo já está esperando.", + "Ein Ei wartet bereits.") + } 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 freshEggDiscardShiny: String { t("이로치 놓아주기", "Send shiny off", "手放す", "Soltar variocolor", "Laisser partir le chromatique", "Soltar o shiny", "Schillerndes Pokémon verabschieden") } diff --git a/Sources/PokeTokenBar/UI/CompanionView.swift b/Sources/PokeTokenBar/UI/CompanionView.swift index a9783b44..4f7ddc07 100644 --- a/Sources/PokeTokenBar/UI/CompanionView.swift +++ b/Sources/PokeTokenBar/UI/CompanionView.swift @@ -839,6 +839,8 @@ private struct BoxGridView: View { @State private var selectedID: String? @State private var page = 0 @State private var confirming = false + /// 개체별 현재 형태 이름(mon.id → 이름). 라인 조회가 필요해 비동기로 채운다(라인은 base 단위 캐시). + @State private var names: [String: String] = [:] private static let columns = 4 private static let rows = 6 @@ -847,19 +849,37 @@ private struct BoxGridView: View { var body: some View { let mons = store.boxedPokemon - if mons.isEmpty { + if mons.isEmpty && !store.hasQueuedEgg { emptyState } else { let pageCount = max(1, (mons.count + Self.pageSize - 1) / Self.pageSize) let current = min(page, pageCount - 1) let slice = Array(mons.dropFirst(current * Self.pageSize).prefix(Self.pageSize)) VStack(alignment: .leading, spacing: 8) { + if store.hasQueuedEgg { waitingEggCard } grid(slice) footer(current: current, pageCount: pageCount) } + .task(id: mons.map(\.id)) { names = await store.boxDisplayNames() } } } + /// 예약해 둔(구매한) 알 — 지금 포켓몬이 졸업하면 활성 슬롯으로 들어온다. 보증 등급이 있으면 표시. + private var waitingEggCard: some View { + HStack(spacing: 10) { + SpriteView(speciesID: nil, size: 26).frame(width: 30, height: 30) + VStack(alignment: .leading, spacing: 1) { + Text(store.l.boxWaitingEgg).font(.system(size: 11, weight: .semibold)) + Text(store.queuedEggTier.map { store.l.eggGuaranteeHint($0) } ?? store.l.boxWaitingEggHint) + .font(.system(size: 10)).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer() + } + .padding(8) + .background(RoundedRectangle(cornerRadius: 10).fill(Color.secondary.opacity(0.08))) + } + private var emptyState: some View { VStack(spacing: 10) { SpriteView(speciesID: 143, size: 96, animated: true) // 잠자는 마스코트(가방과 같은 톤) @@ -880,7 +900,7 @@ private struct BoxGridView: View { let i = row * Self.columns + col if i < slice.count { let mon = slice[i] - BoxMonCell(mon: mon, isSelected: selectedID == mon.id) { + BoxMonCell(mon: mon, name: names[mon.id], isSelected: selectedID == mon.id) { selectedID = (selectedID == mon.id) ? nil : mon.id confirming = false } @@ -954,6 +974,7 @@ private struct BoxGridView: View { @MainActor private struct BoxMonCell: View { let mon: MonState + let name: String? let isSelected: Bool let onTap: () -> Void @@ -968,9 +989,9 @@ private struct BoxMonCell: View { Button(action: onTap) { VStack(spacing: 3) { SpriteView(speciesID: mon.currentID, size: 40, shiny: mon.isShiny) - Text("#\(mon.currentID)") - .font(.system(size: 10, weight: .semibold)).monospacedDigit() - .lineLimit(1) + Text(name ?? "#\(mon.currentID)") + .font(.system(size: 10, weight: .semibold)) + .lineLimit(1).minimumScaleFactor(0.8) if mon.isComplete { Image(systemName: "checkmark.seal.fill") .font(.system(size: 9)).foregroundStyle(.green) diff --git a/Sources/PokeTokenBar/UI/ShopView.swift b/Sources/PokeTokenBar/UI/ShopView.swift index b02d1982..54b0c470 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 } private var price: Int { FreshEgg.price(guaranteeing: tier) } @@ -182,8 +182,11 @@ private struct EggCard: View { .font(.caption2).foregroundStyle(.tertiary).monospacedDigit() Spacer() if !store.hasActive { - // 알 상태 — 리롤 대상이 없어 구매만 막는다(canBuyEgg 게이트). 항목을 숨기는 대신 - // 비활성 버튼으로 "상점에 있긴 하다"를 보이고, 사유는 아래 한 줄로. + // 알 상태 — 뒤에서 자랄 개체가 없어 구매만 막는다. 항목은 남기고 사유는 아래 한 줄로. + Button(l.buy) {} + .buttonStyle(.bordered).controlSize(.small).disabled(true) + } else if store.hasQueuedEgg { + // 이미 알 하나가 예약돼 있다 — 한 번에 하나만 대기. Button(l.buy) {} .buttonStyle(.bordered).controlSize(.small).disabled(true) } else if store.canBuyEgg(tier) { @@ -197,37 +200,31 @@ private struct EggCard: View { Text(l.eggShopLockedHint) .font(.caption2).foregroundStyle(.tertiary) .fixedSize(horizontal: false, vertical: true) + } else if store.hasQueuedEgg { + Text(l.eggQueuedHint) + .font(.caption2).foregroundStyle(.tertiary) + .fixedSize(horizontal: false, vertical: true) } } case .confirm: HStack(spacing: 8) { - Text(l.eggConfirm(store.displayName, l.eggName(tier))) + Text(l.eggQueueConfirm(l.eggName(tier))) .font(.caption2).foregroundStyle(.secondary).lineLimit(2) Spacer() - // 이로치면 한 번 더 경고, 아니면 즉시 실행. - Button(l.buy) { - if store.currentIsShiny { stage = .shinyConfirm } else { commit() } - } - .buttonStyle(.borderedProminent).controlSize(.small) - Button(l.cancel) { stage = .idle } - .buttonStyle(.borderless).controlSize(.small) - } - case .shinyConfirm: - HStack(spacing: 8) { - Text(l.freshEggShinyWarning) - .font(.caption2.weight(.semibold)).foregroundStyle(.orange).lineLimit(2) - Spacer() - Button(l.freshEggDiscardShiny) { commit() } - .buttonStyle(.borderedProminent).controlSize(.small).tint(.orange) + Button(l.buy) { commit() } + .buttonStyle(.borderedProminent).controlSize(.small) Button(l.cancel) { stage = .idle } .buttonStyle(.borderless).controlSize(.small) } } } - /// 리롤 실행 → 새 알을 볼 수 있게 Home 으로 전환(가방 사용과 동일 패턴). + /// 예약 실행 → 대기 중인 알을 볼 수 있게 박스로 전환. private func commit() { stage = .idle - if store.buyEgg(tier) { nav.tab = .home } + if store.buyEgg(tier) { + nav.tab = .collection + nav.collectionSection = .box + } } } diff --git a/Tests/PokeTokenBarTests/CompanionTests.swift b/Tests/PokeTokenBarTests/CompanionTests.swift index 644acdb1..6d4b92d2 100644 --- a/Tests/PokeTokenBarTests/CompanionTests.swift +++ b/Tests/PokeTokenBarTests/CompanionTests.swift @@ -256,6 +256,25 @@ final class CompanionStoreTests: XCTestCase { XCTAssertEqual(s.state.box.count, 1, "no re-graduation into the box") } + /// [Box] Boxed Pokémon (incl. reached pre-evolutions) appear in the Pokédex, and switching the + /// active companion does not remove species from it. + func testBoxSpeciesAppearInPokedexAndSurviveSwitch() throws { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("poke-boxdex-\(UUID().uuidString).json") + let json = """ + {"active":{"id":"A","baseID":1,"pathIDs":[1],"stageIndex":0,"usedAtStage":0,"rarity":"common","totalForms":3}, + "box":[{"id":"B","baseID":4,"pathIDs":[4,5],"stageIndex":1,"usedAtStage":0,"rarity":"uncommon","totalForms":3}]} + """ + try Data(json.utf8).write(to: url) + let s = CompanionStore(provider: StubProvider(value: linear3), clock: { fixedNow }, + fileURL: url, rng: SeededRNG(seed: 7)) + XCTAssertTrue(Set(s.dexSpecies.map(\.id)).isSuperset(of: [1, 4, 5]), + "active (1) and box reached stages (4,5) all show") + + XCTAssertTrue(s.setActiveFromBox(id: "B")) + XCTAssertTrue(Set(s.dexSpecies.map(\.id)).isSuperset(of: [1, 4, 5]), + "same species after switching — the Pokédex doesn't lose entries") + } + // MARK: 상태 파일 decode 복원력 (회귀) /// [회귀] 도감 항목 하나가 손상돼도(구버전/필드 누락) 나머지 도감·companion·인벤토리를 지킨다 — @@ -674,14 +693,11 @@ final class CompanionStoreTests: XCTestCase { XCTAssertEqual(s.dexSpecies.map(\.isRaising), [false, true, false]) } - // MARK: 놓아줌 (알 구매로 포기한 개체의 영구 기록) + // MARK: Fresh egg (queued — active keeps growing, egg waits for graduation) - /// [회귀·트리거] 3단 라인을 2단까지 키우다 놓아주면 **도달한 두 형태만** 남는다. - /// - /// 트리거 분기: `pathIDs` 는 실현 경로, `plannedPathIDs` 는 전체 계획이다. 놓아줌 기록에 - /// 계획을 쓰면 한 번도 본 적 없는 최종 진화형이 보유로 잡힌다 — 알을 사서 포기하는 것이 - /// 도감을 채우는 지름길이 된다. `dexSpecies` 가 육성 중 쓰는 prefix 규칙과 같아야 한다. - func testReleasingMidChainCreditsOnlyReachedForms() throws { + /// [Phase E] Buying an egg mid-chain does NOT release the active: it keeps its reached forms and + /// growth, nothing is written to the dex, and the egg is only queued. + func testBuyEggMidChainKeepsActiveAndQueues() throws { let active = MonState(baseID: 1, pathIDs: [1, 2], plannedPathIDs: [1, 2, 3], stageIndex: 1, usedAtStage: 0, rarity: .common, totalForms: 3) let url = FileManager.default.temporaryDirectory.appendingPathComponent("poke-\(UUID().uuidString).json") @@ -690,31 +706,33 @@ final class CompanionStoreTests: XCTestCase { let s = CompanionStore(provider: StubProvider(value: linear3), clock: { fixedNow }, fileURL: url, rng: SeededRNG(seed: 7)) - XCTAssertEqual(s.dexSpecies.map(\.id), [1, 2], "육성 중 도달분") XCTAssertTrue(s.buyFreshEgg()) - - XCTAssertEqual(s.dexSpecies.map(\.id), [1, 2], "놓아준 뒤에도 같은 두 종") - let released = try XCTUnwrap(s.state.dex.last) - XCTAssertEqual(released.chainOrder, [1, 2]) - XCTAssertEqual(released.finalID, 2, "도달한 마지막 형태") - XCTAssertFalse(s.dexSpecies.contains { $0.id == 3 }, "미도달 진화형은 보유가 아니다") + XCTAssertEqual(s.state.active?.pathIDs, [1, 2], "active is untouched — nothing is released") + XCTAssertTrue(s.state.dex.isEmpty, "no released dex entry is created anymore") + XCTAssertTrue(s.hasQueuedEgg, "the purchased egg is queued") + XCTAssertEqual(s.state.collectedFinals, [], "probability weighting unchanged") } - /// 위장 중인 메타몽을 놓아주면 이로치는 계속 숨겨진다 — `currentIsShiny` 단일 판정을 따른다. - /// 기록에 `a.isShiny` 를 그대로 쓰면 놓아주는 것이 리빌 수단이 된다. - func testReleasingDisguisedDittoKeepsShinyHidden() throws { - let active = MonState(baseID: 1, pathIDs: [1], stageIndex: 0, usedAtStage: 0, - rarity: .common, totalForms: 3, isShiny: true, - dittoDisguise: 1, dittoRevealed: false) + /// [Phase E] The queued egg (with its guarantee) becomes the active egg when the active graduates. + func testQueuedEggActivatesOnGraduation() async { let url = FileManager.default.temporaryDirectory.appendingPathComponent("poke-\(UUID().uuidString).json") - let activeJSON = String(decoding: try JSONEncoder().encode(active), as: UTF8.self) - try Data(#"{"active":\#(activeJSON),"usedSinceInstall":5000000000}"#.utf8).write(to: url) - - let s = CompanionStore(provider: StubProvider(value: linear3), clock: { fixedNow }, + // Seed a large wallet so we can afford an egg without over-growing the active via `use`. + try? Data(#"{"installBaselineSet":true,"usedSinceInstall":10000000000,"active":null,"dex":[],"collectedFinals":[]}"#.utf8) + .write(to: url) + let s = CompanionStore(provider: StubProvider(value: noEvo), clock: { fixedNow }, fileURL: url, rng: SeededRNG(seed: 7)) - XCTAssertTrue(s.buyFreshEgg()) - let released = try XCTUnwrap(s.state.dex.last) - XCTAssertFalse(released.isShiny, "위장 중이면 리빌 전까지 숨김") + base(s) + use(s, PokemonBalance.eggHatchThreshold) // hatch the first egg + await s.hatchIfNeeded() + XCTAssertNotNil(s.state.active) + XCTAssertTrue(s.buyEgg(.uncommon), "10B wallet affords the uncommon egg") + XCTAssertEqual(s.queuedEggTier, .uncommon) + + s.applyUsage(PokemonBalance.graduationTotal(.common)) // graduate the common active + + XCTAssertNil(s.state.active, "graduated → egg incubating") + XCTAssertFalse(s.hasQueuedEgg, "queued egg consumed") + XCTAssertEqual(s.state.eggTier, .uncommon, "the guarantee carried into the active egg") } /// 이 필드 이전에 저장된 항목은 전부 졸업분으로 읽힌다 — 별도 마이그레이션 없이 nil = 졸업. @@ -1601,6 +1619,18 @@ final class CompanionIdentityTests: XCTestCase { XCTAssertEqual(round.queuedEgg?.tier, .uncommon) } + /// [Box] Ownership includes boxed individuals — reached stages count, unreached ones don't, + /// and a boxed shiny is recognized. + func testOwnsSpeciesIncludesBox() { + var s = CompanionState() + s.box = [MonState(baseID: 4, pathIDs: [4, 5], stageIndex: 1, usedAtStage: 0, + rarity: .uncommon, totalForms: 3, isShiny: true)] + XCTAssertTrue(s.ownsSpecies(4), "reached pre-evolution counts") + XCTAssertTrue(s.ownsSpecies(5), "current form counts") + XCTAssertFalse(s.ownsSpecies(6), "unreached stage does not") + XCTAssertTrue(s.ownsShinySpecies(5), "a boxed shiny is recognized") + } + /// [출시 안전] 손상된 상태 파일: active.pathIDs 가 비면 그 active 만 nil(알)로 폴백하되 나머지 상태는 /// 보존한다(필드별 관대화). 깨진 active 를 살려두면 currentID out-of-bounds 위험이므로 nil 이어야 한다 /// (예전엔 전체 디코드를 throw 시켜 상태 전면 초기화 → 도감·인벤토리까지 유실됐다). diff --git a/Tests/PokeTokenBarTests/FreshEggTests.swift b/Tests/PokeTokenBarTests/FreshEggTests.swift index 3ccca148..4126317d 100644 --- a/Tests/PokeTokenBarTests/FreshEggTests.swift +++ b/Tests/PokeTokenBarTests/FreshEggTests.swift @@ -1,7 +1,7 @@ import XCTest @testable import PokeTokenBar -// MARK: 새 알 (리롤 — 현재 포켓몬 폐기, 도감·확률 무영향) +// MARK: Fresh egg (queued — the active companion keeps growing; the egg waits for graduation) private struct FreshEggNoProvider: PokeProviding { func line(baseSpeciesID: Int) async throws -> EvoLine { throw URLError(.notConnectedToInternet) } @@ -13,8 +13,8 @@ private struct FreshEggNoProvider: PokeProviding { final class FreshEggTests: XCTestCase { private let now = Date(timeIntervalSince1970: 1_700_000_000) - /// 활성 포켓몬(baseID 10, common 3형태, 성장 200M) + 도감 1개 + 수집기록 1개(1:3) + 지갑. - /// active=false 면 알(활성 없음) 상태. + /// Active Pokémon (baseID 10, common 3-form, 200M grown) + 1 dex entry + 1 collected pair + wallet. + /// active=false means the egg (no active) state. private func store(active: Bool = true, shiny: Bool = false, used: Int = 5_000_000_000, spent: Int = 0) -> CompanionStore { let url = FileManager.default.temporaryDirectory.appendingPathComponent("egg-\(UUID().uuidString).json") @@ -29,61 +29,64 @@ final class FreshEggTests: XCTestCase { func testPriceIsOneBillion() { XCTAssertEqual(FreshEgg.price, 1_000_000_000) } - /// [핵심] 리롤 = 놓아줌: active 사라지고 새 알(eggUsage 0). - /// 도감에는 **놓아줌 기록으로 남고**, 확률 가중(collectedFinals)은 여전히 불변이다 — - /// 끝까지 키운 게 아니므로 최종체 완성으로 세지 않는다. - func testBuyFreshEggReleasesIntoDexWithoutProbabilityImpact() { + /// [core] Buying an egg does NOT release the active — it keeps growing. The egg is queued and the + /// wallet is charged; the dex and probability weighting (collectedFinals) are untouched. + func testBuyFreshEggQueuesWithoutReleasingActive() { let s = store(used: 5_000_000_000, spent: 0) - let persistedDexBefore = s.state.dex + let dexCountBefore = s.state.dex.count let collectedBefore = s.state.collectedFinals - XCTAssertEqual(s.dexEntries.count, persistedDexBefore.count + 1, - "현재 포켓몬은 졸업 전에도 도감 화면에 표시") XCTAssertTrue(s.hasActive) - XCTAssertTrue(s.buyFreshEgg()) - XCTAssertNil(s.state.active, "현재 포켓몬은 더 이상 활성이 아니다") - XCTAssertTrue(s.isEgg) - XCTAssertEqual(s.state.eggUsage, 0, "새 알은 처음부터 인큐베이션") - XCTAssertNil(s.state.pendingHatchID) - XCTAssertEqual(s.state.dex.count, persistedDexBefore.count + 1, - "놓아준 개체가 영구 기록으로 추가된다") - let released = try? XCTUnwrap(s.state.dex.last) - XCTAssertEqual(released?.baseID, 10) - XCTAssertTrue(released?.isReleased ?? false, "졸업분과 구분되는 놓아줌 기록") - XCTAssertEqual(released?.chainOrder, [10], "도달한 형태만 — stageIndex 0 이라 1형태") - XCTAssertEqual(s.dexEntries.count, persistedDexBefore.count + 1, - "합성 엔트리는 사라지고 영구 기록이 그 자리를 대신한다") + XCTAssertTrue(s.buyFreshEgg()) - XCTAssertEqual(s.state.collectedFinals, collectedBefore, "확률 가중(collectedFinals) 불변") - XCTAssertEqual(s.state.spentTokens, FreshEgg.price, "지갑에서 1B 차감") + XCTAssertNotNil(s.state.active, "the active companion keeps growing — it is not released") + XCTAssertEqual(s.state.active?.baseID, 10) + XCTAssertFalse(s.isEgg, "still raising the active, not incubating") + XCTAssertTrue(s.hasQueuedEgg, "the purchased egg is now waiting") + XCTAssertNil(s.queuedEggTier, "a plain fresh egg has no rarity guarantee") + XCTAssertEqual(s.state.dex.count, dexCountBefore, "nothing is released into the dex") + XCTAssertEqual(s.state.collectedFinals, collectedBefore, "probability weighting unchanged") + XCTAssertEqual(s.state.spentTokens, FreshEgg.price, "wallet charged 1B") XCTAssertEqual(s.availableTokens, 5_000_000_000 - FreshEgg.price) } - /// [회귀] 놓아준 종은 도감에서 사라지지 않는다. - /// - /// 이게 이 기능의 존재 이유다. 도감은 "쌓이기만 한다"는 약속을 주는데, 알 구매가 유일하게 - /// 그 약속을 깨는 경로였다 — 현재 개체에서만 오던 종이 통째로 빠져 종 수가 줄었다. - /// `state.dex` 에 남기는 대신 `state.active = nil` 만 하면 이 테스트가 실패한다(주입 확인함). - func testReleasedSpeciesStaysInTheDex() { + /// A guaranteed (premium) egg stores its rarity floor on the queued egg. + func testGuaranteedEggStoresTierOnQueue() { + let s = store(used: 10_000_000_000) + XCTAssertTrue(s.buyEgg(.uncommon)) + XCTAssertTrue(s.hasQueuedEgg) + XCTAssertEqual(s.queuedEggTier, .uncommon) + XCTAssertEqual(s.state.spentTokens, FreshEgg.price(guaranteeing: .uncommon)) + } + + /// Only one egg can wait at a time — a second purchase is a no-op. + func testCannotQueueTwoEggs() { + let s = store(used: 10_000_000_000) + XCTAssertTrue(s.buyFreshEgg()) + XCTAssertFalse(s.canBuyFreshEgg, "one already queued") + XCTAssertFalse(s.buyFreshEgg()) + XCTAssertEqual(s.state.spentTokens, FreshEgg.price, "charged only once") + } + + /// The species stays in the Pokédex — buying an egg no longer removes it (the active is untouched). + func testActiveSpeciesStaysInDexAfterBuying() { let s = store() - XCTAssertTrue(s.dexSpecies.contains { $0.id == 10 }, "육성 중엔 도감에 보인다") + XCTAssertTrue(s.dexSpecies.contains { $0.id == 10 }, "raising → shown in the Pokédex") XCTAssertTrue(s.buyFreshEgg()) - XCTAssertTrue(s.dexSpecies.contains { $0.id == 10 }, - "놓아준 뒤에도 남는다 — 도감 종 수는 줄지 않는다") - XCTAssertFalse(s.dexSpecies.first { $0.id == 10 }?.isRaising ?? true, - "더 이상 키우는 중이 아니므로 Raising 뱃지는 없다") + XCTAssertTrue(s.dexSpecies.contains { $0.id == 10 }, "still shown — nothing was released") + XCTAssertTrue(s.dexSpecies.first { $0.id == 10 }?.isRaising ?? false, "still the active buddy") } - /// 폐기한 개체(baseID 10)의 종은 collectedFinals 에 들어가지 않는다(이후 부화 확률에 영향 없음). - func testDiscardedSpeciesNotCollected() { + /// Buying an egg does not touch collectedFinals (probability weighting). + func testBuyingEggDoesNotCollect() { let s = store() XCTAssertTrue(s.buyFreshEgg()) XCTAssertFalse(s.state.collectedFinals.contains { $0.hasPrefix("10:") }, - "폐기 개체 종은 수집 기록에 없어야 함") + "the active's species is not added to the collected set") } - /// 알 상태(활성 없음)에선 리롤할 게 없어 불가. - func testCannotRerollWhenEgg() { + /// In the egg state (no active) there is nothing to grow behind, so buying is blocked. + func testCannotBuyWhenEgg() { let s = store(active: false, used: 5_000_000_000) XCTAssertFalse(s.hasActive) XCTAssertFalse(s.canBuyFreshEgg) @@ -91,21 +94,21 @@ final class FreshEggTests: XCTestCase { XCTAssertEqual(s.state.spentTokens, 0, "no-op") } - /// 잔액이 가격 미만이면 불가 — 활성 유지. - func testCannotRerollWithoutFunds() { - let s = store(used: 500_000_000) // 1B 미만 + /// Insufficient funds → blocked; the active stays. + func testCannotBuyWithoutFunds() { + let s = store(used: 500_000_000) // below 1B XCTAssertFalse(s.canBuyFreshEgg) XCTAssertFalse(s.buyFreshEgg()) - XCTAssertNotNil(s.state.active, "활성 유지") + XCTAssertNotNil(s.state.active, "active retained") XCTAssertEqual(s.state.spentTokens, 0) } - /// 이로치도 폐기 가능(추가 경고는 UI 단계, 로직은 동일) — 리롤 후 흔적 없음. - func testShinyCanBeRerolled() { + /// A shiny active is NOT released by buying an egg — it keeps growing (no discard, no warning). + func testBuyingEggKeepsShinyActive() { let s = store(shiny: true) XCTAssertTrue(s.currentIsShiny) XCTAssertTrue(s.buyFreshEgg()) - XCTAssertNil(s.state.active) - XCTAssertFalse(s.currentIsShiny) + XCTAssertNotNil(s.state.active, "the shiny keeps growing") + XCTAssertTrue(s.currentIsShiny, "still shiny and active") } } diff --git a/Tests/PokeTokenBarTests/PremiumEggTests.swift b/Tests/PokeTokenBarTests/PremiumEggTests.swift index 4f2c89a3..9b8e1270 100644 --- a/Tests/PokeTokenBarTests/PremiumEggTests.swift +++ b/Tests/PokeTokenBarTests/PremiumEggTests.swift @@ -173,19 +173,17 @@ final class PremiumEggTests: XCTestCase { // MARK: 구매 — 게이트·차감·보증 기록 - func testBuyPremiumEggRecordsGuaranteeAndDebitsTierPrice() { + func testBuyPremiumEggQueuesGuaranteeAndDebitsTierPrice() { let s = activeStore() XCTAssertTrue(s.canBuyEgg(.rare)) XCTAssertTrue(s.buyEgg(.rare)) - XCTAssertEqual(s.state.eggTier, .rare, "보증이 상태에 기록됨") - XCTAssertEqual(s.eggGuarantee, .rare) + XCTAssertEqual(s.queuedEggTier, .rare, "the guarantee is stored on the queued egg") + XCTAssertTrue(s.hasQueuedEgg) XCTAssertEqual(s.state.spentTokens, FreshEgg.price(guaranteeing: .rare)) - XCTAssertNil(s.state.active, "현재 포켓몬은 더 이상 활성이 아니다") - XCTAssertEqual(s.state.eggUsage, 0, "새 알은 처음부터 인큐베이션") - // 등급 알도 새 알과 같은 놓아줌 경로를 쓴다 — 종은 남기되 졸업으로 세지는 않는다. - XCTAssertEqual(s.state.dex.count, 1, "놓아준 개체가 기록으로 남는다") - XCTAssertTrue(s.state.dex.first?.isReleased ?? false, "졸업이 아니라 놓아줌") - XCTAssertTrue(s.state.collectedFinals.isEmpty, "최종체 완성으로 세지 않는다") + XCTAssertNotNil(s.state.active, "the active companion is not released — it keeps growing") + XCTAssertNil(s.state.eggTier, "the active egg slot is untouched until graduation") + XCTAssertTrue(s.state.dex.isEmpty, "nothing is released into the dex") + XCTAssertTrue(s.state.collectedFinals.isEmpty) } /// 알 상태에선 등급 알도 못 산다 — 기존 새 알과 게이트 통일. @@ -216,23 +214,23 @@ final class PremiumEggTests: XCTestCase { /// (활성 포켓몬과 pre-roll 은 애초에 공존하지 않는다: 프리패치는 알 상태에서만 돌고 `hatchIfNeeded` /// 가 부화 직전 비운다. 그 불변식이 밖에서 들어온 파일에도 유지되는지는 /// `testSanitizedDropsGuaranteeAndItsPreRollWhenActiveExists` 가 지킨다.) - func testPurchaseStartsFromCleanRollState() { + func testPurchaseQueuesWithoutTouchingActiveEggState() { let s = activeStore() XCTAssertNil(s.state.pendingHatchID, "활성 포켓몬이 있는 동안엔 pre-roll 이 없다") XCTAssertTrue(s.buyEgg(.rare)) - XCTAssertEqual(s.state.eggTier, .rare) - XCTAssertNil(s.state.pendingHatchID, "새 보증으로 다시 롤한다") - XCTAssertEqual(s.state.eggUsage, 0) + XCTAssertEqual(s.queuedEggTier, .rare, "the guarantee waits on the queued egg") + XCTAssertNil(s.state.eggTier, "the active egg slot is untouched until graduation") + XCTAssertNil(s.state.pendingHatchID, "no pre-roll is started while the companion grows") } /// 보증은 재시작을 건너 살아남아야 한다 — 구매 시점엔 종을 못 정하기 때문(롤에 네트워크 필요). - func testGuaranteeSurvivesRestart() { + func testQueuedGuaranteeSurvivesRestart() { let f = url() let s1 = activeStore(at: f) XCTAssertTrue(s1.buyEgg(.uncommon)) let s2 = CompanionStore(provider: TieredProvider(), clock: { self.now }, fileURL: f, rng: SeededRNG(seed: 7)) - XCTAssertEqual(s2.state.eggTier, .uncommon, "재시작 후에도 산 보증 유지") - XCTAssertEqual(s2.eggGuarantee, .uncommon) + XCTAssertEqual(s2.queuedEggTier, .uncommon, "the purchased guarantee survives a restart") + XCTAssertTrue(s2.hasQueuedEgg) } // MARK: ② 가중 선택이 하한 미만을 뽑지 않는다 (대조군 포함) From 5ffbbbfc045054630f02866684c97fbda56d64cb Mon Sep 17 00:00:00 2001 From: Seb1337 <16237592+Seb1337@users.noreply.github.com> Date: Thu, 3 Sep 2026 20:37:07 +0200 Subject: [PATCH 5/8] Egg purchase parks active in box and hatches immediately (swap model); distinguish final-form from graduated --- .../PokeTokenBar/Core/CompanionModel.swift | 10 --- .../PokeTokenBar/Core/CompanionStore.swift | 40 ++++++------ Sources/PokeTokenBar/Core/Localization.swift | 64 ++++++------------- Sources/PokeTokenBar/Core/SaveTransfer.swift | 2 +- Sources/PokeTokenBar/UI/CompanionView.swift | 19 +----- Sources/PokeTokenBar/UI/ShopView.swift | 17 +---- Tests/PokeTokenBarTests/CompanionTests.swift | 64 +++++++++---------- Tests/PokeTokenBarTests/FreshEggTests.swift | 60 ++++++++--------- Tests/PokeTokenBarTests/PremiumEggTests.swift | 26 ++++---- .../PokeTokenBarTests/SaveTransferTests.swift | 4 +- 10 files changed, 124 insertions(+), 182 deletions(-) diff --git a/Sources/PokeTokenBar/Core/CompanionModel.swift b/Sources/PokeTokenBar/Core/CompanionModel.swift index 54040f26..1ffe8a3f 100644 --- a/Sources/PokeTokenBar/Core/CompanionModel.swift +++ b/Sources/PokeTokenBar/Core/CompanionModel.swift @@ -537,13 +537,6 @@ private extension KeyedDecodingContainer { } } -/// A purchased egg parked while the active Pokémon keeps growing. Wrapping it in an optional -/// (`queuedEgg: QueuedEgg?`) distinguishes "no egg waiting" (nil) from "an egg with no rarity -/// guarantee waiting" (.some(tier: nil)) — a plain `Rarity?` could not tell those apart. -struct QueuedEgg: Codable, Sendable { - var tier: Rarity? -} - /// 영속 상태(Application Support JSON). 포켓몬 전환 — 이전 커스텀 캐릭터 상태는 폐기(새로 시작). struct CompanionState: Codable, Sendable { // 토큰: 설치 이후만 측정 @@ -578,8 +571,6 @@ struct CompanionState: Codable, Sendable { // Box/bank: stored individuals — still-growing parked mons and completed buddies. // Each keeps its full MonState, so token status (stageIndex, usedAtStage) survives verbatim. var box: [MonState] = [] - // A purchased egg waiting to become active on the next graduation. nil = none waiting. - var queuedEgg: QueuedEgg? = nil // 도감 var dex: [DexEntry] = [] // 소유한 (base,final) 쌍 — 분기 다양성용 @@ -621,7 +612,6 @@ struct CompanionState: Codable, Sendable { representativeSpeciesID = c.lenientOptional(Int.self, forKey: .representativeSpeciesID) // Box is per-item isolated (Lossy wrapper) like dex — one corrupt entry won't drop the array. box = c.lenient([Lossy].self, forKey: .box, default: []).compactMap(\.value) - queuedEgg = c.lenientOptional(QueuedEgg.self, forKey: .queuedEgg) // 도감은 항목별 격리 — 손상 항목 하나가 도감 전체를 날리지 않게. dex = c.lenient([Lossy].self, forKey: .dex, default: []).compactMap(\.value) collectedFinals = c.lenient(Set.self, forKey: .collectedFinals, default: []) diff --git a/Sources/PokeTokenBar/Core/CompanionStore.swift b/Sources/PokeTokenBar/Core/CompanionStore.swift index 144d9a26..9c1fedcc 100644 --- a/Sources/PokeTokenBar/Core/CompanionStore.swift +++ b/Sources/PokeTokenBar/Core/CompanionStore.swift @@ -594,12 +594,7 @@ final class CompanionStore { activeGeneration += 1 currentLine = nil state.eggUsage = 0 // 새 알은 처음부터 인큐베이션 - // A purchased egg that was waiting behind this companion now moves into the freshly emptied - // egg slot (its guarantee, if any). With no queued egg the next egg is a plain, unguaranteed - // one (eggTier = nil) — reaching here means there was an active companion, so any prior - // guarantee was already consumed at its own hatch. - state.eggTier = state.queuedEgg?.tier - state.queuedEgg = nil + state.eggTier = nil // graduation always starts a plain, unguaranteed egg // "알을 받는 순간" 즉시 프리패칭 시작 — 다음 부화의 종·라인·스프라이트 예열. Task { await self.ensureEggPrefetch() } } @@ -786,30 +781,37 @@ final class CompanionStore { /// The shop still lists the entry during egg incubation; this gate only blocks the purchase. func canBuyEgg(_ tier: Rarity?) -> Bool { guard FreshEgg.shopTiers.contains(tier) else { return false } - return hasActive && state.queuedEgg == nil - && availableTokens >= FreshEgg.price(guaranteeing: tier) + return hasActive && availableTokens >= FreshEgg.price(guaranteeing: tier) } - /// Buy an egg — the current companion is NOT released or discarded. It keeps growing; the egg - /// waits (`queuedEgg`) and becomes active at the next graduation (see `graduate()`). The wallet - /// is charged now and the rarity guarantee (if any) is stored on the queued egg; the species is + /// Buy an egg — the current companion is NOT released; it is parked in the box (its growth + /// preserved) and a fresh egg starts incubating immediately in the active slot. The wallet is + /// charged now and the rarity guarantee (if any) is stored on the egg (`eggTier`); the species is /// rolled later at hatch, so an offline purchase never loses tokens to a failed roll. @discardableResult func buyEgg(_ tier: Rarity?) -> Bool { guard canBuyEgg(tier) else { return false } state.spentTokens += FreshEgg.price(guaranteeing: tier) - state.queuedEgg = QueuedEgg(tier: tier) - AppLog.write("egg queued: tier=\(tier?.rawValue ?? "none")") + // The current companion is kept — parked in the box (NOT released, its growth preserved) — + // and a fresh egg starts incubating immediately in the now-empty active slot. + if let a = state.active { + state.box.append(a) + } + state.active = nil + state.reconcileRepresentativeSelection() + activeGeneration += 1 + currentLine = nil + state.eggUsage = 0 // fresh egg incubates from scratch + state.eggTier = tier // rarity guarantee (nil = none) + state.pendingHatchID = nil + prefetchedLineID = nil + justGraduated = nil; justEvolvedTo = nil; eventUntil = nil + AppLog.write("egg purchased: parked active in box, tier=\(tier?.rawValue ?? "none")") + Task { await self.ensureEggPrefetch() } // pre-warm the next hatch save() return true } - /// A purchased egg waiting behind the active companion. Surfaced to the UI so the box can show a - /// "waiting egg" card and the shop can reflect that another can't be queued yet. `queuedEggTier` - /// is only meaningful when `hasQueuedEgg` is true (nil there means "no rarity guarantee"). - var hasQueuedEgg: Bool { state.queuedEgg != nil } - var queuedEggTier: Rarity? { state.queuedEgg?.tier } - // 보증 없는 기본 알 래퍼 — 기존 호출부/테스트 호환. var canBuyFreshEgg: Bool { canBuyEgg(nil) } @discardableResult diff --git a/Sources/PokeTokenBar/Core/Localization.swift b/Sources/PokeTokenBar/Core/Localization.swift index f0db469e..47ef4768 100644 --- a/Sources/PokeTokenBar/Core/Localization.swift +++ b/Sources/PokeTokenBar/Core/Localization.swift @@ -592,8 +592,6 @@ struct L { var boxSetActive: String { t("활성 포켓몬으로", "Make active", "アクティブにする", "Hacer activo", "Rendre actif", "Tornar ativo", "Als aktives Pokémon") } var boxSwitchConfirm: String { t("교체", "Switch", "交代", "Cambiar", "Échanger", "Trocar", "Übernehmen") } var boxSwitchDisabledHint: String { t("지금은 알을 품는 중이라 교체할 수 없어요.", "Can't switch while an egg is incubating.", "タマゴを育てている間は交代できません。", "No puedes cambiar mientras incuba un huevo.", "Impossible d'échanger pendant l'incubation d'un œuf.", "Não dá para trocar enquanto um ovo choca.", "Während ein Ei ausgebrütet wird, ist kein Wechsel möglich.") } - var boxWaitingEgg: String { t("대기 중인 알", "Waiting egg", "待機中のタマゴ", "Huevo en espera", "Œuf en attente", "Ovo à espera", "Wartendes Ei") } - var boxWaitingEggHint: String { t("지금 포켓몬이 졸업하면 부화를 시작해요.", "Starts hatching after your active Pokémon graduates.", "今のポケモンが卒業すると孵り始めます。", "Empieza a eclosionar cuando tu Pokémon activo se gradúa.", "Éclot après la remise de diplôme de ton Pokémon actif.", "Começa a chocar quando seu Pokémon ativo se forma.", "Beginnt zu schlüpfen, sobald dein aktives Pokémon seinen Abschluss macht.") } var dexRaising: String { t("키우는 중", "Raising", "育成中", "Criando", "En élevage", "Treinando", "In Aufzucht") } /// 포획 로그에서 졸업분과 놓아준 개체를 가르는 표식. 종은 도감에 남고 개체 기록만 이 뱃지를 단다. var dexReleased: String { t("놓아줌", "Released", "逃がした", "Liberado", "Relâché", "Solto", "Freigelassen") } @@ -846,22 +844,22 @@ struct L { } func eggDescription(_ tier: Rarity?) -> String { guard let tier, tier != .common else { - return t("네 포켓몬은 그대로 크고, 이 알은 졸업 뒤에 부화를 시작해요.", - "Your Pokémon keeps growing; this egg starts hatching after it graduates.", - "今のポケモンはそのまま育ち、このタマゴは卒業後に孵り始めます。", - "Tu Pokémon sigue creciendo; este huevo empieza a eclosionar tras su graduación.", - "Ton Pokémon continue de grandir ; cet œuf éclot après sa remise de diplôme.", - "Seu Pokémon continua crescendo; este ovo começa a chocar após a formatura.", - "Dein Pokémon wächst weiter; dieses Ei beginnt nach dessen Abschluss zu schlüpfen.") + return t("지금 포켓몬을 박스에 보관하고 새 알을 바로 품기 시작해요.", + "Parks your current Pokémon in the box and starts hatching a new egg right away.", + "今のポケモンをボックスに預けて、新しいタマゴをすぐ育て始めます。", + "Guarda a tu Pokémon actual en la caja y empieza a incubar un huevo nuevo enseguida.", + "Range ton Pokémon actuel dans la boîte et fait éclore un nouvel œuf tout de suite.", + "Guarda seu Pokémon atual na caixa e começa a chocar um ovo novo na hora.", + "Legt dein aktuelles Pokémon in die Box und beginnt sofort, ein neues Ei auszubrüten.") } let r = rarityLabel(tier) - return t("네 포켓몬은 그대로 크고, 졸업 뒤에 \(r) 이상 확정 알이 부화를 시작해요.", - "Your Pokémon keeps growing; after it graduates, an egg guaranteed \(r) or better starts hatching.", - "今のポケモンはそのまま育ち、卒業後に \(r) 以上確定のタマゴが孵り始めます。", - "Tu Pokémon sigue creciendo; tras su graduación eclosiona un huevo garantizado de \(r) o superior.", - "Ton Pokémon continue de grandir ; après sa remise de diplôme éclot un œuf garanti \(r) ou mieux.", - "Seu Pokémon continua crescendo; após a formatura começa a chocar um ovo garantido de \(r) ou melhor.", - "Dein Pokémon wächst weiter; nach dessen Abschluss beginnt ein Ei mit Garantie \(r) oder besser zu schlüpfen.") + return t("지금 포켓몬을 박스에 보관하고 \(r) 이상 확정 알을 바로 품기 시작해요.", + "Parks your current Pokémon in the box and starts hatching an egg guaranteed \(r) or better.", + "今のポケモンをボックスに預けて、\(r) 以上確定のタマゴをすぐ育て始めます。", + "Guarda a tu Pokémon actual en la caja e incuba un huevo garantizado de \(r) o superior.", + "Range ton Pokémon actuel dans la boîte et fait éclore un œuf garanti \(r) ou mieux.", + "Guarda seu Pokémon atual na caixa e choca um ovo garantido de \(r) ou melhor.", + "Legt dein aktuelles Pokémon in die Box und brütet sofort ein Ei mit Garantie \(r) oder besser aus.") } /// 알 상태의 상점 알 카드 비활성 사유 — 항목은 보이되 구매 버튼 아래에 한 줄로 붙는다(EggCard). var eggShopLockedHint: String { @@ -879,33 +877,13 @@ struct L { return t("\(r) 이상 확정", "\(r) or better", "\(r) 以上確定", "\(r) o superior garantizado", "\(r) ou mieux garanti", "\(r) ou melhor garantido", "Garantiert \(r) oder besser") } func eggConfirm(_ monName: String, _ eggName: String) -> String { - t("\(monName)을(를) 놓아주고 \(eggName)(으)로 바꿀까요?", - "Send off \(monName) for the \(eggName)?", - "\(monName) を手放して \(eggName) にしますか?", - "¿Soltar a \(monName) y cambiarlo por \(eggName)?", - "Laisser partir \(monName) pour le \(eggName) ?", - "Soltar \(monName) e trocar pelo \(eggName)?", - "\(monName) verabschieden und gegen \(eggName) tauschen?") - } - /// EggCard 구매 확인 — 놓아주는 게 아니라 "예약해 두고 졸업 때 부화"하는 새 의미. - func eggQueueConfirm(_ eggName: String) -> String { - t("\(eggName)을(를) 예약할까요? 지금 포켓몬이 졸업하면 부화를 시작해요.", - "Queue the \(eggName)? It starts hatching after your current Pokémon graduates.", - "\(eggName) を予約しますか?今のポケモンが卒業すると孵り始めます。", - "¿Reservar el \(eggName)? Empezará a eclosionar cuando tu Pokémon actual se gradúe.", - "Réserver le \(eggName) ? Il éclora après la remise de diplôme de ton Pokémon actuel.", - "Reservar o \(eggName)? Ele começa a chocar quando seu Pokémon atual se formar.", - "Das \(eggName) vormerken? Es schlüpft, sobald dein aktuelles Pokémon seinen Abschluss macht.") - } - /// 이미 예약된 알이 있을 때 상점 카드 하단 안내 — 한 번에 하나만 대기한다. - var eggQueuedHint: String { - t("이미 알 하나가 대기 중이에요.", - "An egg is already waiting.", - "すでにタマゴが1つ待機中です。", - "Ya hay un huevo esperando.", - "Un œuf attend déjà.", - "Um ovo já está esperando.", - "Ein Ei wartet bereits.") + t("\(monName)을(를) 박스에 보관하고 \(eggName)을(를) 품을까요?", + "Keep \(monName) in the box and hatch the \(eggName)?", + "\(monName) をボックスに預けて \(eggName) を育てますか?", + "¿Guardar a \(monName) en la caja e incubar el \(eggName)?", + "Ranger \(monName) dans la boîte et faire éclore le \(eggName) ?", + "Guardar \(monName) na caixa e chocar o \(eggName)?", + "\(monName) in der Box behalten und das \(eggName) ausbrüten?") } 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 freshEggDiscardShiny: String { t("이로치 놓아주기", "Send shiny off", "手放す", "Soltar variocolor", "Laisser partir le chromatique", "Soltar o shiny", "Schillerndes Pokémon verabschieden") } diff --git a/Sources/PokeTokenBar/Core/SaveTransfer.swift b/Sources/PokeTokenBar/Core/SaveTransfer.swift index 6deece25..12be742f 100644 --- a/Sources/PokeTokenBar/Core/SaveTransfer.swift +++ b/Sources/PokeTokenBar/Core/SaveTransfer.swift @@ -173,7 +173,7 @@ enum SaveTransfer { /// 다른 기기에서 온 상태를 **이 기기 기준으로 재정렬**한다. /// /// `CompanionState` 의 필드는 이전 관점에서 세 부류다. - /// - **진행**: 어느 기기에서든 참(`usedSinceInstall`·`dex`·`inventory`·`active`·`box`·`queuedEgg`· + /// - **진행**: 어느 기기에서든 참(`usedSinceInstall`·`dex`·`inventory`·`active`·`box`· /// `eggUsage`·`eggTier`·`representativeSpeciesID`…) /// → 그대로. 알 보증(`eggTier`)은 산 물건이지 이 기기의 장부가 아니라 기기를 옮겨도 따라간다. /// - **로컬 장부**: *그 기기가* 어디까지 적립했나(`claimedTodayTokensByProvider`·`lastDate`·`installBaselineSet`) diff --git a/Sources/PokeTokenBar/UI/CompanionView.swift b/Sources/PokeTokenBar/UI/CompanionView.swift index 4f7ddc07..01e120ef 100644 --- a/Sources/PokeTokenBar/UI/CompanionView.swift +++ b/Sources/PokeTokenBar/UI/CompanionView.swift @@ -849,14 +849,13 @@ private struct BoxGridView: View { var body: some View { let mons = store.boxedPokemon - if mons.isEmpty && !store.hasQueuedEgg { + if mons.isEmpty { emptyState } else { let pageCount = max(1, (mons.count + Self.pageSize - 1) / Self.pageSize) let current = min(page, pageCount - 1) let slice = Array(mons.dropFirst(current * Self.pageSize).prefix(Self.pageSize)) VStack(alignment: .leading, spacing: 8) { - if store.hasQueuedEgg { waitingEggCard } grid(slice) footer(current: current, pageCount: pageCount) } @@ -864,22 +863,6 @@ private struct BoxGridView: View { } } - /// 예약해 둔(구매한) 알 — 지금 포켓몬이 졸업하면 활성 슬롯으로 들어온다. 보증 등급이 있으면 표시. - private var waitingEggCard: some View { - HStack(spacing: 10) { - SpriteView(speciesID: nil, size: 26).frame(width: 30, height: 30) - VStack(alignment: .leading, spacing: 1) { - Text(store.l.boxWaitingEgg).font(.system(size: 11, weight: .semibold)) - Text(store.queuedEggTier.map { store.l.eggGuaranteeHint($0) } ?? store.l.boxWaitingEggHint) - .font(.system(size: 10)).foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - Spacer() - } - .padding(8) - .background(RoundedRectangle(cornerRadius: 10).fill(Color.secondary.opacity(0.08))) - } - private var emptyState: some View { VStack(spacing: 10) { SpriteView(speciesID: 143, size: 96, animated: true) // 잠자는 마스코트(가방과 같은 톤) diff --git a/Sources/PokeTokenBar/UI/ShopView.swift b/Sources/PokeTokenBar/UI/ShopView.swift index 54b0c470..24fda5e5 100644 --- a/Sources/PokeTokenBar/UI/ShopView.swift +++ b/Sources/PokeTokenBar/UI/ShopView.swift @@ -185,10 +185,6 @@ private struct EggCard: View { // 알 상태 — 뒤에서 자랄 개체가 없어 구매만 막는다. 항목은 남기고 사유는 아래 한 줄로. Button(l.buy) {} .buttonStyle(.bordered).controlSize(.small).disabled(true) - } else if store.hasQueuedEgg { - // 이미 알 하나가 예약돼 있다 — 한 번에 하나만 대기. - Button(l.buy) {} - .buttonStyle(.bordered).controlSize(.small).disabled(true) } else if store.canBuyEgg(tier) { Button(l.buy) { stage = .confirm } .buttonStyle(.bordered).controlSize(.small) @@ -200,15 +196,11 @@ private struct EggCard: View { Text(l.eggShopLockedHint) .font(.caption2).foregroundStyle(.tertiary) .fixedSize(horizontal: false, vertical: true) - } else if store.hasQueuedEgg { - Text(l.eggQueuedHint) - .font(.caption2).foregroundStyle(.tertiary) - .fixedSize(horizontal: false, vertical: true) } } case .confirm: HStack(spacing: 8) { - Text(l.eggQueueConfirm(l.eggName(tier))) + Text(l.eggConfirm(store.displayName, l.eggName(tier))) .font(.caption2).foregroundStyle(.secondary).lineLimit(2) Spacer() Button(l.buy) { commit() } @@ -219,12 +211,9 @@ private struct EggCard: View { } } - /// 예약 실행 → 대기 중인 알을 볼 수 있게 박스로 전환. + /// 구매 실행 → 새로 품는 알을 볼 수 있게 Home 으로 전환(현재 개체는 박스에 보관됨). private func commit() { stage = .idle - if store.buyEgg(tier) { - nav.tab = .collection - nav.collectionSection = .box - } + if store.buyEgg(tier) { nav.tab = .home } } } diff --git a/Tests/PokeTokenBarTests/CompanionTests.swift b/Tests/PokeTokenBarTests/CompanionTests.swift index 6d4b92d2..0f5ccee5 100644 --- a/Tests/PokeTokenBarTests/CompanionTests.swift +++ b/Tests/PokeTokenBarTests/CompanionTests.swift @@ -256,6 +256,26 @@ final class CompanionStoreTests: XCTestCase { XCTAssertEqual(s.state.box.count, 1, "no re-graduation into the box") } + /// [Box] Reaching the final form is NOT the same as graduating: a final-form mon below the + /// graduation threshold stays active and growing (isComplete stays false) until it truly graduates. + func testFinalFormIsNotCompleteUntilGraduation() async { + let s = store(noEvo) + base(s) + use(s, PokemonBalance.eggHatchThreshold) + await s.hatchIfNeeded() + XCTAssertNotNil(s.state.active, "hatched a final-form (single-form) mon") + XCTAssertEqual(s.state.active?.isComplete, false, "final form, but not graduated") + + s.applyUsage(PokemonBalance.graduationTotal(.common) - 1) // one short of graduating + XCTAssertNotNil(s.state.active, "still active — not yet graduated") + XCTAssertEqual(s.state.active?.isComplete, false, "still not complete at the final form") + XCTAssertTrue(s.state.box.isEmpty, "nothing boxed yet") + + s.applyUsage(1) // cross the threshold → graduate + XCTAssertNil(s.state.active, "now graduated → egg") + XCTAssertEqual(s.state.box.first?.isComplete, true, "only now is it complete") + } + /// [Box] Boxed Pokémon (incl. reached pre-evolutions) appear in the Pokédex, and switching the /// active companion does not remove species from it. func testBoxSpeciesAppearInPokedexAndSurviveSwitch() throws { @@ -693,13 +713,13 @@ final class CompanionStoreTests: XCTestCase { XCTAssertEqual(s.dexSpecies.map(\.isRaising), [false, true, false]) } - // MARK: Fresh egg (queued — active keeps growing, egg waits for graduation) + // MARK: Fresh egg (swap — active is parked in the box, not released; a new egg hatches) - /// [Phase E] Buying an egg mid-chain does NOT release the active: it keeps its reached forms and - /// growth, nothing is written to the dex, and the egg is only queued. - func testBuyEggMidChainKeepsActiveAndQueues() throws { + /// [Phase E] Buying an egg mid-chain parks the active in the box (NOT released, growth preserved) + /// and starts a fresh egg immediately — nothing is written to the dex. + func testBuyEggMidChainParksActiveInBox() throws { let active = MonState(baseID: 1, pathIDs: [1, 2], plannedPathIDs: [1, 2, 3], stageIndex: 1, - usedAtStage: 0, rarity: .common, totalForms: 3) + usedAtStage: 123, rarity: .common, totalForms: 3) let url = FileManager.default.temporaryDirectory.appendingPathComponent("poke-\(UUID().uuidString).json") let activeJSON = String(decoding: try JSONEncoder().encode(active), as: UTF8.self) try Data(#"{"active":\#(activeJSON),"usedSinceInstall":5000000000}"#.utf8).write(to: url) @@ -707,34 +727,15 @@ final class CompanionStoreTests: XCTestCase { let s = CompanionStore(provider: StubProvider(value: linear3), clock: { fixedNow }, fileURL: url, rng: SeededRNG(seed: 7)) XCTAssertTrue(s.buyFreshEgg()) - XCTAssertEqual(s.state.active?.pathIDs, [1, 2], "active is untouched — nothing is released") - XCTAssertTrue(s.state.dex.isEmpty, "no released dex entry is created anymore") - XCTAssertTrue(s.hasQueuedEgg, "the purchased egg is queued") + XCTAssertNil(s.state.active, "active slot is now the fresh egg") + XCTAssertEqual(s.state.box.count, 1, "the companion is parked, not released") + XCTAssertEqual(s.state.box.last?.pathIDs, [1, 2], "its reached forms are preserved") + XCTAssertEqual(s.state.box.last?.usedAtStage, 123, "its growth is preserved") + XCTAssertFalse(s.state.box.last?.isComplete ?? true, "parked, not graduated") + XCTAssertTrue(s.state.dex.isEmpty, "no released dex entry") XCTAssertEqual(s.state.collectedFinals, [], "probability weighting unchanged") } - /// [Phase E] The queued egg (with its guarantee) becomes the active egg when the active graduates. - func testQueuedEggActivatesOnGraduation() async { - let url = FileManager.default.temporaryDirectory.appendingPathComponent("poke-\(UUID().uuidString).json") - // Seed a large wallet so we can afford an egg without over-growing the active via `use`. - try? Data(#"{"installBaselineSet":true,"usedSinceInstall":10000000000,"active":null,"dex":[],"collectedFinals":[]}"#.utf8) - .write(to: url) - let s = CompanionStore(provider: StubProvider(value: noEvo), clock: { fixedNow }, - fileURL: url, rng: SeededRNG(seed: 7)) - base(s) - use(s, PokemonBalance.eggHatchThreshold) // hatch the first egg - await s.hatchIfNeeded() - XCTAssertNotNil(s.state.active) - XCTAssertTrue(s.buyEgg(.uncommon), "10B wallet affords the uncommon egg") - XCTAssertEqual(s.queuedEggTier, .uncommon) - - s.applyUsage(PokemonBalance.graduationTotal(.common)) // graduate the common active - - XCTAssertNil(s.state.active, "graduated → egg incubating") - XCTAssertFalse(s.hasQueuedEgg, "queued egg consumed") - XCTAssertEqual(s.state.eggTier, .uncommon, "the guarantee carried into the active egg") - } - /// 이 필드 이전에 저장된 항목은 전부 졸업분으로 읽힌다 — 별도 마이그레이션 없이 nil = 졸업. func testLegacyDexEntriesDecodeAsGraduated() throws { let json = #"{"baseID":1,"finalID":3,"chainOrder":[1,2,3],"rarity":"common"}"# @@ -1589,7 +1590,6 @@ final class CompanionIdentityTests: XCTestCase { """ let s = try JSONDecoder().decode(CompanionState.self, from: Data(old.utf8)) XCTAssertTrue(s.box.isEmpty, "legacy save has no box → empty array") - XCTAssertNil(s.queuedEgg, "legacy save has no queued egg") XCTAssertFalse(s.active?.id.isEmpty ?? true, "active mon gets a fresh non-empty id") XCTAssertEqual(s.active?.isComplete, false, "isComplete defaults to false") } @@ -1605,7 +1605,6 @@ final class CompanionIdentityTests: XCTestCase { rarity: .common, totalForms: 1) finished.isComplete = true state.box = [parked, finished] - state.queuedEgg = QueuedEgg(tier: .uncommon) let round = try JSONDecoder().decode(CompanionState.self, from: JSONEncoder().encode(state)) XCTAssertEqual(round.box.count, 2) @@ -1616,7 +1615,6 @@ final class CompanionIdentityTests: XCTestCase { XCTAssertEqual(round.box[0].nature, .adamant) XCTAssertEqual(round.box[0].isComplete, false) XCTAssertEqual(round.box[1].isComplete, true, "completed buddy flag persists") - XCTAssertEqual(round.queuedEgg?.tier, .uncommon) } /// [Box] Ownership includes boxed individuals — reached stages count, unreached ones don't, diff --git a/Tests/PokeTokenBarTests/FreshEggTests.swift b/Tests/PokeTokenBarTests/FreshEggTests.swift index 4126317d..b7dca845 100644 --- a/Tests/PokeTokenBarTests/FreshEggTests.swift +++ b/Tests/PokeTokenBarTests/FreshEggTests.swift @@ -1,7 +1,7 @@ import XCTest @testable import PokeTokenBar -// MARK: Fresh egg (queued — the active companion keeps growing; the egg waits for graduation) +// MARK: Fresh egg (swap — the active companion is parked in the box, not released; a new egg hatches) private struct FreshEggNoProvider: PokeProviding { func line(baseSpeciesID: Int) async throws -> EvoLine { throw URLError(.notConnectedToInternet) } @@ -18,7 +18,7 @@ final class FreshEggTests: XCTestCase { private func store(active: Bool = true, shiny: Bool = false, used: Int = 5_000_000_000, spent: Int = 0) -> CompanionStore { let url = FileManager.default.temporaryDirectory.appendingPathComponent("egg-\(UUID().uuidString).json") - let mon = "{\"baseID\":10,\"pathIDs\":[10],\"stageIndex\":0,\"usedAtStage\":200000000," + let mon = "{\"id\":\"m10\",\"baseID\":10,\"pathIDs\":[10],\"stageIndex\":0,\"usedAtStage\":200000000," + "\"rarity\":\"common\",\"totalForms\":3,\"isShiny\":\(shiny)}" let dex = "{\"baseID\":1,\"finalID\":3,\"chainOrder\":[1,2,3],\"rarity\":\"common\"}" let json = "{\"installBaselineSet\":true,\"usedSinceInstall\":\(used),\"spentTokens\":\(spent)," @@ -29,52 +29,52 @@ final class FreshEggTests: XCTestCase { func testPriceIsOneBillion() { XCTAssertEqual(FreshEgg.price, 1_000_000_000) } - /// [core] Buying an egg does NOT release the active — it keeps growing. The egg is queued and the - /// wallet is charged; the dex and probability weighting (collectedFinals) are untouched. - func testBuyFreshEggQueuesWithoutReleasingActive() { + /// [core] Buying an egg parks the active in the box (NOT released) with its growth preserved and + /// starts a fresh egg immediately. The dex and probability weighting are untouched. + func testBuyFreshEggParksActiveInBoxAndIncubates() { let s = store(used: 5_000_000_000, spent: 0) let dexCountBefore = s.state.dex.count - let collectedBefore = s.state.collectedFinals XCTAssertTrue(s.hasActive) XCTAssertTrue(s.buyFreshEgg()) - XCTAssertNotNil(s.state.active, "the active companion keeps growing — it is not released") - XCTAssertEqual(s.state.active?.baseID, 10) - XCTAssertFalse(s.isEgg, "still raising the active, not incubating") - XCTAssertTrue(s.hasQueuedEgg, "the purchased egg is now waiting") - XCTAssertNil(s.queuedEggTier, "a plain fresh egg has no rarity guarantee") + XCTAssertNil(s.state.active, "active slot is now the fresh egg") + XCTAssertTrue(s.isEgg) + XCTAssertEqual(s.state.box.count, 1, "the previous companion is parked in the box") + XCTAssertEqual(s.state.box.last?.baseID, 10) + XCTAssertEqual(s.state.box.last?.usedAtStage, 200_000_000, "its growth is preserved") + XCTAssertFalse(s.state.box.last?.isComplete ?? true, "parked, not graduated") + XCTAssertEqual(s.state.eggUsage, 0, "fresh egg from scratch") XCTAssertEqual(s.state.dex.count, dexCountBefore, "nothing is released into the dex") - XCTAssertEqual(s.state.collectedFinals, collectedBefore, "probability weighting unchanged") XCTAssertEqual(s.state.spentTokens, FreshEgg.price, "wallet charged 1B") XCTAssertEqual(s.availableTokens, 5_000_000_000 - FreshEgg.price) } - /// A guaranteed (premium) egg stores its rarity floor on the queued egg. - func testGuaranteedEggStoresTierOnQueue() { + /// A guaranteed (premium) egg sets the rarity floor on the freshly incubating egg. + func testGuaranteedEggSetsTierOnFreshEgg() { let s = store(used: 10_000_000_000) XCTAssertTrue(s.buyEgg(.uncommon)) - XCTAssertTrue(s.hasQueuedEgg) - XCTAssertEqual(s.queuedEggTier, .uncommon) + XCTAssertNil(s.state.active) + XCTAssertEqual(s.state.eggTier, .uncommon) XCTAssertEqual(s.state.spentTokens, FreshEgg.price(guaranteeing: .uncommon)) } - /// Only one egg can wait at a time — a second purchase is a no-op. - func testCannotQueueTwoEggs() { + /// After buying, the active slot is an egg — you can't buy again until it hatches. + func testCannotBuyAgainWhileIncubating() { let s = store(used: 10_000_000_000) XCTAssertTrue(s.buyFreshEgg()) - XCTAssertFalse(s.canBuyFreshEgg, "one already queued") + XCTAssertFalse(s.hasActive) + XCTAssertFalse(s.canBuyFreshEgg, "no active to park behind the next egg") XCTAssertFalse(s.buyFreshEgg()) XCTAssertEqual(s.state.spentTokens, FreshEgg.price, "charged only once") } - /// The species stays in the Pokédex — buying an egg no longer removes it (the active is untouched). - func testActiveSpeciesStaysInDexAfterBuying() { + /// The parked species stays in the Pokédex (the box counts toward it) — nothing is lost. + func testParkedSpeciesStaysInDex() { let s = store() - XCTAssertTrue(s.dexSpecies.contains { $0.id == 10 }, "raising → shown in the Pokédex") + XCTAssertTrue(s.dexSpecies.contains { $0.id == 10 }, "raising → shown") XCTAssertTrue(s.buyFreshEgg()) - XCTAssertTrue(s.dexSpecies.contains { $0.id == 10 }, "still shown — nothing was released") - XCTAssertTrue(s.dexSpecies.first { $0.id == 10 }?.isRaising ?? false, "still the active buddy") + XCTAssertTrue(s.dexSpecies.contains { $0.id == 10 }, "still shown — parked in the box, not released") } /// Buying an egg does not touch collectedFinals (probability weighting). @@ -82,10 +82,10 @@ final class FreshEggTests: XCTestCase { let s = store() XCTAssertTrue(s.buyFreshEgg()) XCTAssertFalse(s.state.collectedFinals.contains { $0.hasPrefix("10:") }, - "the active's species is not added to the collected set") + "the parked species is not added to the collected set") } - /// In the egg state (no active) there is nothing to grow behind, so buying is blocked. + /// In the egg state (no active) there is nothing to park, so buying is blocked. func testCannotBuyWhenEgg() { let s = store(active: false, used: 5_000_000_000) XCTAssertFalse(s.hasActive) @@ -103,12 +103,12 @@ final class FreshEggTests: XCTestCase { XCTAssertEqual(s.state.spentTokens, 0) } - /// A shiny active is NOT released by buying an egg — it keeps growing (no discard, no warning). - func testBuyingEggKeepsShinyActive() { + /// A shiny active is NOT released — it is parked in the box (kept), and a new egg starts. + func testBuyingEggParksShinyInBox() { let s = store(shiny: true) XCTAssertTrue(s.currentIsShiny) XCTAssertTrue(s.buyFreshEgg()) - XCTAssertNotNil(s.state.active, "the shiny keeps growing") - XCTAssertTrue(s.currentIsShiny, "still shiny and active") + XCTAssertNil(s.state.active, "the shiny is parked, not active") + XCTAssertEqual(s.state.box.last?.isShiny, true, "the shiny is kept in the box") } } diff --git a/Tests/PokeTokenBarTests/PremiumEggTests.swift b/Tests/PokeTokenBarTests/PremiumEggTests.swift index 9b8e1270..b5e564b2 100644 --- a/Tests/PokeTokenBarTests/PremiumEggTests.swift +++ b/Tests/PokeTokenBarTests/PremiumEggTests.swift @@ -173,15 +173,16 @@ final class PremiumEggTests: XCTestCase { // MARK: 구매 — 게이트·차감·보증 기록 - func testBuyPremiumEggQueuesGuaranteeAndDebitsTierPrice() { + func testBuyPremiumEggParksActiveAndSetsTier() { let s = activeStore() XCTAssertTrue(s.canBuyEgg(.rare)) XCTAssertTrue(s.buyEgg(.rare)) - XCTAssertEqual(s.queuedEggTier, .rare, "the guarantee is stored on the queued egg") - XCTAssertTrue(s.hasQueuedEgg) + XCTAssertNil(s.state.active, "active slot is now the fresh egg") + XCTAssertEqual(s.state.eggTier, .rare, "the guarantee is on the freshly incubating egg") + XCTAssertEqual(s.eggGuarantee, .rare) XCTAssertEqual(s.state.spentTokens, FreshEgg.price(guaranteeing: .rare)) - XCTAssertNotNil(s.state.active, "the active companion is not released — it keeps growing") - XCTAssertNil(s.state.eggTier, "the active egg slot is untouched until graduation") + XCTAssertEqual(s.state.box.count, 1, "the previous companion is parked in the box") + XCTAssertFalse(s.state.box.last?.isComplete ?? true, "parked, not graduated") XCTAssertTrue(s.state.dex.isEmpty, "nothing is released into the dex") XCTAssertTrue(s.state.collectedFinals.isEmpty) } @@ -214,23 +215,24 @@ final class PremiumEggTests: XCTestCase { /// (활성 포켓몬과 pre-roll 은 애초에 공존하지 않는다: 프리패치는 알 상태에서만 돌고 `hatchIfNeeded` /// 가 부화 직전 비운다. 그 불변식이 밖에서 들어온 파일에도 유지되는지는 /// `testSanitizedDropsGuaranteeAndItsPreRollWhenActiveExists` 가 지킨다.) - func testPurchaseQueuesWithoutTouchingActiveEggState() { + func testPurchaseParksActiveAndStartsGuaranteedEgg() { let s = activeStore() XCTAssertNil(s.state.pendingHatchID, "활성 포켓몬이 있는 동안엔 pre-roll 이 없다") XCTAssertTrue(s.buyEgg(.rare)) - XCTAssertEqual(s.queuedEggTier, .rare, "the guarantee waits on the queued egg") - XCTAssertNil(s.state.eggTier, "the active egg slot is untouched until graduation") - XCTAssertNil(s.state.pendingHatchID, "no pre-roll is started while the companion grows") + XCTAssertEqual(s.state.eggTier, .rare, "the fresh egg carries the guarantee") + XCTAssertNil(s.state.pendingHatchID, "fresh egg rolls from the new guarantee") + XCTAssertEqual(s.state.eggUsage, 0) + XCTAssertEqual(s.state.box.count, 1, "the previous companion is parked") } /// 보증은 재시작을 건너 살아남아야 한다 — 구매 시점엔 종을 못 정하기 때문(롤에 네트워크 필요). - func testQueuedGuaranteeSurvivesRestart() { + func testGuaranteeSurvivesRestart() { let f = url() let s1 = activeStore(at: f) XCTAssertTrue(s1.buyEgg(.uncommon)) let s2 = CompanionStore(provider: TieredProvider(), clock: { self.now }, fileURL: f, rng: SeededRNG(seed: 7)) - XCTAssertEqual(s2.queuedEggTier, .uncommon, "the purchased guarantee survives a restart") - XCTAssertTrue(s2.hasQueuedEgg) + XCTAssertEqual(s2.state.eggTier, .uncommon, "the purchased guarantee survives a restart") + XCTAssertEqual(s2.eggGuarantee, .uncommon) } // MARK: ② 가중 선택이 하한 미만을 뽑지 않는다 (대조군 포함) diff --git a/Tests/PokeTokenBarTests/SaveTransferTests.swift b/Tests/PokeTokenBarTests/SaveTransferTests.swift index 35ebfa3a..469c8432 100644 --- a/Tests/PokeTokenBarTests/SaveTransferTests.swift +++ b/Tests/PokeTokenBarTests/SaveTransferTests.swift @@ -512,10 +512,10 @@ final class SaveTransferTests: XCTestCase { /// 딸려 들어간다(`language` 가 실제로 그랬다). 필드 목록을 테스트로 고정해 **분류를 강제**한다. func testEveryCompanionStateFieldIsClassifiedForTransfer() { // eggTier(알 등급 보증) = 진행 — 산 물건이지 이 기기의 장부가 아니라 기기를 옮겨도 따라간다. - // box(보관 개체)·queuedEgg(대기 중 산 알) = 진행 — 수집·구매물이라 기기를 옮겨도 따라간다. + // box(보관 개체) = 진행 — 수집물이라 기기를 옮겨도 따라간다. let progress: Set = ["usedSinceInstall", "spentTokens", "eggUsage", "eggTier", "pendingHatchID", "active", "representativeSpeciesID", "dex", - "collectedFinals", "inventory", "box", "queuedEgg"] + "collectedFinals", "inventory", "box"] let deviceLedger: Set = ["installBaselineSet", "claimedTodayTokensByProvider", "lastDate"] let accountLedger: Set = ["candyGrantTier", "candyFeatureSeeded"] let devicePreference: Set = ["language"] From cf6648e072a4e26ec0706451eb2e8ec8ed08b238 Mon Sep 17 00:00:00 2001 From: Seb1337 <16237592+Seb1337@users.noreply.github.com> Date: Thu, 3 Sep 2026 20:49:12 +0200 Subject: [PATCH 6/8] =?UTF-8?q?Disable=20activating=20graduated=20mons=20(?= =?UTF-8?q?trophies);=20keep=20their=20evolution=20line=20in=20the=20Pok?= =?UTF-8?q?=C3=A9dex?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PokeTokenBar/Core/CompanionStore.swift | 3 +++ Sources/PokeTokenBar/Core/Localization.swift | 1 + Sources/PokeTokenBar/UI/CompanionView.swift | 16 ++++++++--- Tests/PokeTokenBarTests/CompanionTests.swift | 27 +++++++++++++------ 4 files changed, 35 insertions(+), 12 deletions(-) diff --git a/Sources/PokeTokenBar/Core/CompanionStore.swift b/Sources/PokeTokenBar/Core/CompanionStore.swift index 9c1fedcc..cb0fc37a 100644 --- a/Sources/PokeTokenBar/Core/CompanionStore.swift +++ b/Sources/PokeTokenBar/Core/CompanionStore.swift @@ -610,10 +610,13 @@ final class CompanionStore { /// Make a boxed Pokémon the active one; the current active moves into the box (a swap). /// Growth status (stageIndex/usedAtStage) is preserved because the whole MonState is moved. /// Not allowed while the active slot is an egg (nil) — avoids parking egg incubation state. + /// A graduated (completed) Pokémon is a trophy and cannot be set active — it can't be raised + /// further, and activating it would let asset normalization reset its stored evolution line. @discardableResult func setActiveFromBox(id: String) -> Bool { guard let active = state.active else { return false } guard let idx = state.box.firstIndex(where: { $0.id == id }) else { return false } + guard !state.box[idx].isComplete else { return false } let picked = state.box.remove(at: idx) state.box.append(active) // the swap — previous active goes to the box state.active = picked diff --git a/Sources/PokeTokenBar/Core/Localization.swift b/Sources/PokeTokenBar/Core/Localization.swift index 47ef4768..97fb7189 100644 --- a/Sources/PokeTokenBar/Core/Localization.swift +++ b/Sources/PokeTokenBar/Core/Localization.swift @@ -592,6 +592,7 @@ struct L { var boxSetActive: String { t("활성 포켓몬으로", "Make active", "アクティブにする", "Hacer activo", "Rendre actif", "Tornar ativo", "Als aktives Pokémon") } var boxSwitchConfirm: String { t("교체", "Switch", "交代", "Cambiar", "Échanger", "Trocar", "Übernehmen") } var boxSwitchDisabledHint: String { t("지금은 알을 품는 중이라 교체할 수 없어요.", "Can't switch while an egg is incubating.", "タマゴを育てている間は交代できません。", "No puedes cambiar mientras incuba un huevo.", "Impossible d'échanger pendant l'incubation d'un œuf.", "Não dá para trocar enquanto um ovo choca.", "Während ein Ei ausgebrütet wird, ist kein Wechsel möglich.") } + var boxGraduatedHint: String { t("졸업한 포켓몬이라 트로피예요 — 활성으로 세울 수 없어요.", "A graduated Pokémon is a trophy — it can't be set active.", "卒業したポケモンはトロフィーです — アクティブにできません。", "Un Pokémon graduado es un trofeo: no puede activarse.", "Un Pokémon diplômé est un trophée — impossible de l'activer.", "Um Pokémon formado é um troféu — não pode ficar ativo.", "Ein graduiertes Pokémon ist eine Trophäe — kann nicht aktiv gesetzt werden.") } var dexRaising: String { t("키우는 중", "Raising", "育成中", "Criando", "En élevage", "Treinando", "In Aufzucht") } /// 포획 로그에서 졸업분과 놓아준 개체를 가르는 표식. 종은 도감에 남고 개체 기록만 이 뱃지를 단다. var dexReleased: String { t("놓아줌", "Released", "逃がした", "Liberado", "Relâché", "Solto", "Freigelassen") } diff --git a/Sources/PokeTokenBar/UI/CompanionView.swift b/Sources/PokeTokenBar/UI/CompanionView.swift index 01e120ef..8fc6839f 100644 --- a/Sources/PokeTokenBar/UI/CompanionView.swift +++ b/Sources/PokeTokenBar/UI/CompanionView.swift @@ -924,11 +924,14 @@ private struct BoxGridView: View { .frame(height: 22) } - /// 선택한 개체를 활성으로 만드는 인라인 액션. 알 품는 중엔 버튼 대신 이유를 띄운다. + /// 선택한 개체를 활성으로 만드는 인라인 액션. 졸업 트로피는 활성 불가, 알 품는 중엔 버튼 대신 이유를 띄운다. @ViewBuilder private var action: some View { if let id = selectedID { - if !store.canSwitchActive { + if store.boxedPokemon.first(where: { $0.id == id })?.isComplete == true { + Text(store.l.boxGraduatedHint) + .font(.system(size: 9)).foregroundStyle(.secondary).lineLimit(2) + } else if !store.canSwitchActive { Text(store.l.boxSwitchDisabledHint) .font(.system(size: 9)).foregroundStyle(.secondary).lineLimit(2) } else if confirming { @@ -971,13 +974,18 @@ private struct BoxMonCell: View { var body: some View { Button(action: onTap) { VStack(spacing: 3) { + // 졸업 트로피는 스프라이트 우상단에 🏆 뱃지 — 한눈에 구분된다. SpriteView(speciesID: mon.currentID, size: 40, shiny: mon.isShiny) + .overlay(alignment: .topTrailing) { + if mon.isComplete { + Text("🏆").font(.system(size: 13)).offset(x: 3, y: -3) + } + } Text(name ?? "#\(mon.currentID)") .font(.system(size: 10, weight: .semibold)) .lineLimit(1).minimumScaleFactor(0.8) if mon.isComplete { - Image(systemName: "checkmark.seal.fill") - .font(.system(size: 9)).foregroundStyle(.green) + Color.clear.frame(height: 3) // 진행 바 자리를 비워 칸 높이를 맞춘다(트로피는 스프라이트에 표시). } else { ProgressView(value: progress) .progressViewStyle(.linear) diff --git a/Tests/PokeTokenBarTests/CompanionTests.swift b/Tests/PokeTokenBarTests/CompanionTests.swift index 0f5ccee5..6a33682b 100644 --- a/Tests/PokeTokenBarTests/CompanionTests.swift +++ b/Tests/PokeTokenBarTests/CompanionTests.swift @@ -236,24 +236,35 @@ final class CompanionStoreTests: XCTestCase { XCTAssertEqual(s.state.dex.count, dexBefore + 1, "permanent dex record still added") } - /// [Box] A completed buddy re-activated from the box does not grow or re-graduate. - func testCompletedBuddyDoesNotRegrow() throws { + /// [Box] A graduated (completed) Pokémon is a trophy — it cannot be set active from the box. + func testCannotActivateGraduatedMon() throws { let url = FileManager.default.temporaryDirectory.appendingPathComponent("poke-buddy-\(UUID().uuidString).json") let json = """ {"active":{"id":"A","baseID":1,"pathIDs":[1],"stageIndex":0,"usedAtStage":0,"rarity":"common","totalForms":1}, - "box":[{"id":"DONE","baseID":25,"pathIDs":[25],"stageIndex":0,"usedAtStage":0,"rarity":"common","totalForms":1,"isComplete":true}]} + "box":[{"id":"DONE","baseID":25,"pathIDs":[25,26],"stageIndex":1,"usedAtStage":0,"rarity":"common","totalForms":2,"isComplete":true}]} """ try Data(json.utf8).write(to: url) let s = CompanionStore(provider: StubProvider(value: linear3), clock: { fixedNow }, fileURL: url, rng: SeededRNG(seed: 7)) - XCTAssertTrue(s.setActiveFromBox(id: "DONE")) - XCTAssertEqual(s.state.active?.id, "DONE") + XCTAssertFalse(s.setActiveFromBox(id: "DONE"), "graduated trophies can't be activated") + XCTAssertEqual(s.state.active?.id, "A", "active is unchanged") + XCTAssertEqual(s.state.box.map(\.id), ["DONE"], "the trophy stays in the box") + } + /// [Box] Defensive: even if a graduated mon is somehow the active one (e.g. a hand-edited save), + /// applyUsage does not grow or re-graduate it. + func testActiveGraduatedMonDoesNotGrow() throws { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("poke-buddy-\(UUID().uuidString).json") + let json = """ + {"active":{"id":"DONE","baseID":25,"pathIDs":[25,26],"stageIndex":1,"usedAtStage":0,"rarity":"common","totalForms":2,"isComplete":true}} + """ + try Data(json.utf8).write(to: url) + let s = CompanionStore(provider: StubProvider(value: linear3), clock: { fixedNow }, + fileURL: url, rng: SeededRNG(seed: 7)) s.applyUsage(PokemonBalance.graduationTotal(.common) * 2) // would graduate a normal mon - - XCTAssertEqual(s.state.active?.id, "DONE", "completed buddy stays active") + XCTAssertEqual(s.state.active?.id, "DONE", "still active") XCTAssertEqual(s.state.active?.usedAtStage, 0, "no growth accrued") - XCTAssertEqual(s.state.box.count, 1, "no re-graduation into the box") + XCTAssertTrue(s.state.box.isEmpty, "no re-graduation into the box") } /// [Box] Reaching the final form is NOT the same as graduating: a final-form mon below the From f9c408041b1a38826471d396d2db668f89db13a8 Mon Sep 17 00:00:00 2001 From: Seb1337 <16237592+Seb1337@users.noreply.github.com> Date: Thu, 3 Sep 2026 20:52:49 +0200 Subject: [PATCH 7/8] =?UTF-8?q?Show=20trophy=20badge=20on=20graduated=20bo?= =?UTF-8?q?x=20Pok=C3=A9mon?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/PokeTokenBar/UI/CompanionView.swift | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/Sources/PokeTokenBar/UI/CompanionView.swift b/Sources/PokeTokenBar/UI/CompanionView.swift index 8fc6839f..73e93ee0 100644 --- a/Sources/PokeTokenBar/UI/CompanionView.swift +++ b/Sources/PokeTokenBar/UI/CompanionView.swift @@ -974,18 +974,12 @@ private struct BoxMonCell: View { var body: some View { Button(action: onTap) { VStack(spacing: 3) { - // 졸업 트로피는 스프라이트 우상단에 🏆 뱃지 — 한눈에 구분된다. SpriteView(speciesID: mon.currentID, size: 40, shiny: mon.isShiny) - .overlay(alignment: .topTrailing) { - if mon.isComplete { - Text("🏆").font(.system(size: 13)).offset(x: 3, y: -3) - } - } Text(name ?? "#\(mon.currentID)") .font(.system(size: 10, weight: .semibold)) .lineLimit(1).minimumScaleFactor(0.8) if mon.isComplete { - Color.clear.frame(height: 3) // 진행 바 자리를 비워 칸 높이를 맞춘다(트로피는 스프라이트에 표시). + Text("🏆").font(.system(size: 11)) // 졸업 트로피 — 진행 바 자리에 표시. } else { ProgressView(value: progress) .progressViewStyle(.linear) From 0f22dbc8d828a1404758529df8deba1488431b16 Mon Sep 17 00:00:00 2001 From: Seb1337 <16237592+Seb1337@users.noreply.github.com> Date: Thu, 3 Sep 2026 21:02:46 +0200 Subject: [PATCH 8/8] Clamp box entries at the save-transfer trust boundary --- Sources/PokeTokenBar/Core/SaveTransfer.swift | 21 ++++++++++++------- .../PokeTokenBarTests/SaveTransferTests.swift | 7 +++++++ 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/Sources/PokeTokenBar/Core/SaveTransfer.swift b/Sources/PokeTokenBar/Core/SaveTransfer.swift index 12be742f..70214e91 100644 --- a/Sources/PokeTokenBar/Core/SaveTransfer.swift +++ b/Sources/PokeTokenBar/Core/SaveTransfer.swift @@ -141,6 +141,16 @@ enum SaveTransfer { /// 정규화한다. 대상은 실제로 산술에 쓰이는 필드뿐이다 — 도감·인벤토리 항목은 잘라내지 않는다(데이터 손실). static func sanitized(_ state: CompanionState) -> CompanionState { func clampToken(_ v: Int) -> Int { min(max(0, v), maxTokenValue) } + // Clamp a stored Pokémon's numeric fields — used for the active companion and every box + // entry, which all cross the same import trust boundary. + func clampMon(_ input: MonState) -> MonState { + var m = input + m.usedAtStage = clampToken(m.usedAtStage) + // totalForms feeds `kk * (kk + 1)` in PokemonBalance.phaseThreshold — a large value is a trap. + m.totalForms = min(max(1, m.totalForms), 12) + m.stageIndex = min(max(0, m.stageIndex), max(0, m.pathIDs.count - 1)) + return m + } var s = state s.usedSinceInstall = clampToken(s.usedSinceInstall) s.spentTokens = clampToken(s.spentTokens) @@ -159,13 +169,10 @@ enum SaveTransfer { // load() 의 .corrupt 복구도 안 걸려 파일을 손으로 지우기 전엔 앱을 못 쓴다. // 관대 디코딩은 모르는 rawValue 만 걸러낼 뿐 **아는데 만족 불가능한 값**은 그대로 통과시킨다. if s.eggTier?.captureRateCeiling == nil { s.eggTier = nil } - if var active = s.active { - active.usedAtStage = clampToken(active.usedAtStage) - // totalForms 는 `kk * (kk + 1)` 형태로 쓰여(PokemonBalance.phaseThreshold) 큰 값이 그 자체로 트랩이다. - active.totalForms = min(max(1, active.totalForms), 12) - active.stageIndex = min(max(0, active.stageIndex), max(0, active.pathIDs.count - 1)) - s.active = active - } + if let active = s.active { s.active = clampMon(active) } + // Box entries come from the same trust boundary — clamp them like the active mon so a + // hand-edited import can't smuggle an out-of-range totalForms/stageIndex into storage. + s.box = s.box.map(clampMon) s.reconcileRepresentativeSelection() return s } diff --git a/Tests/PokeTokenBarTests/SaveTransferTests.swift b/Tests/PokeTokenBarTests/SaveTransferTests.swift index 469c8432..0dbedaf9 100644 --- a/Tests/PokeTokenBarTests/SaveTransferTests.swift +++ b/Tests/PokeTokenBarTests/SaveTransferTests.swift @@ -458,6 +458,9 @@ final class SaveTransferTests: XCTestCase { evil.active = MonState(baseID: 1, pathIDs: [1], plannedPathIDs: [1], stageIndex: Int.max, usedAtStage: Int.max, rarity: .common, totalForms: Int.max) + evil.box = [MonState(baseID: 4, pathIDs: [4, 5], plannedPathIDs: [4, 5], + stageIndex: Int.max, usedAtStage: Int.max, rarity: .common, + totalForms: Int.max)] let data = try SaveTransfer.encode(state: evil, appVersion: "2.5.0", deviceName: "Corrupt", now: transferNow) @@ -471,6 +474,10 @@ final class SaveTransferTests: XCTestCase { XCTAssertEqual(s.active?.usedAtStage, SaveTransfer.maxTokenValue) XCTAssertEqual(s.active?.totalForms, 12) XCTAssertEqual(s.active?.stageIndex, 0, "pathIDs 범위를 넘지 않아야 한다") + // Box entries cross the same trust boundary and are clamped identically. + XCTAssertEqual(s.box.first?.usedAtStage, SaveTransfer.maxTokenValue, "box entry clamped too") + XCTAssertEqual(s.box.first?.totalForms, 12) + XCTAssertEqual(s.box.first?.stageIndex, 1, "clamped into the [4,5] pathIDs range") // 정규화된 값으로 실제 산술 경로를 태워 트랩이 안 나는지 확인한다. let url = tempURL("clamped")