diff --git a/Sources/PokeTokenBar/Core/CompanionModel.swift b/Sources/PokeTokenBar/Core/CompanionModel.swift index 8e0b04a0..e8189b7f 100644 --- a/Sources/PokeTokenBar/Core/CompanionModel.swift +++ b/Sources/PokeTokenBar/Core/CompanionModel.swift @@ -449,6 +449,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] // 사전에 선택한 전체 진화 경로 @@ -464,6 +467,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)] } var phaseThreshold: Int { @@ -500,6 +506,9 @@ struct MonState: Codable, Sendable { // 하위호환 디코딩: 구버전 저장에 없는 부화 속성은 기본값. 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 가 기본(알)로 폴백되게 한다. @@ -638,6 +647,9 @@ 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] = [] // 도감 var dex: [DexEntry] = [] // 소유한 (base,final) 쌍 — 분기 다양성용 @@ -677,6 +689,8 @@ 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) // 도감은 항목별 격리 — 손상 항목 하나가 도감 전체를 날리지 않게. dex = c.lenient([Lossy].self, forKey: .dex, default: []).compactMap(\.value) collectedFinals = c.lenient(Set.self, forKey: .collectedFinals, default: []) @@ -690,6 +704,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) } @@ -702,6 +718,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 bdf0275f..c7c66d30 100644 --- a/Sources/PokeTokenBar/Core/CompanionStore.swift +++ b/Sources/PokeTokenBar/Core/CompanionStore.swift @@ -270,36 +270,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( - id: a.profile?.instanceID ?? UUID().uuidString, - baseID: a.baseID, - finalID: chain.last ?? a.baseID, - chainOrder: chain, - rarity: a.rarity, - caughtAt: now, - isShiny: currentIsShiny, - nature: a.nature, - profile: a.profile, - 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] @@ -375,6 +345,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, @@ -555,6 +534,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 reconcileActiveProfileGrowth() guard let line = currentLine else { save(); return } @@ -690,17 +671,63 @@ 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 currentLine = nil state.eggUsage = 0 // 새 알은 처음부터 인큐베이션 - // eggTier 는 손대지 않는다 — 여기 도달했다는 건 활성 포켓몬이 있었다는 뜻이라 보증은 이미 nil 이다 - // (부화가 소비, 디스크/불러오기는 sanitized 가 정규화). 소비 지점은 hatchCore 한 곳으로 유지한다. + state.eggTier = nil // graduation always starts a plain, unguaranteed egg // "알을 받는 순간" 즉시 프리패칭 시작 — 다음 부화의 종·라인·스프라이트 예열. 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. + /// 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 + 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 + } + + /// 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) } @@ -838,47 +865,39 @@ 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 >= price(of: .egg(tier)) } - /// 알 구매 — 현재 포켓몬을 놓아주고 처음부터 인큐베이션하는 새 알로. 지갑에서 가격 차감. - /// graduate() 의 알-리셋을 미러링하되, 놓아준 개체는 **도감에 남긴다**(`releasedDexEntry`). - /// 도감은 "쌓이기만 한다"는 약속을 주는데, 여기가 종이 사라질 수 있던 유일한 경로였다. - /// `collectedFinals`(최종체 완성·분기 가중)는 여전히 손대지 않는다 — 끝까지 키운 게 아니다. - /// 성장(usedAtStage)은 소멸(추가 비용). - /// - /// 여기서 종을 롤하지 않는다 — 롤에는 네트워크가 필요해서 오프라인이면 토큰만 사라진다. 보증만 - /// 상태(`eggTier`)에 적고, 실제 롤은 프리패치/부화 경로가 그 보증을 읽어 수행한다. + /// 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 += price(of: .egg(tier)) + // 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.dex.append(releasedDexEntry(from: a)) // 놓아줌 기록 — 도감에서 종이 사라지지 않게 + state.box.append(a) } - state.active = nil // 놓아줌 (졸업 아님 — collectedFinals 는 미변경) - // 놓아준 종도 이제 dex 에 있으므로 대표 선택은 유지된다. 손상 상태 파일 등으로 정말 보유가 - // 끊긴 경우만 자동 추적으로 복귀한다. + state.active = nil state.reconcileRepresentativeSelection() activeGeneration += 1 currentLine = nil - state.eggUsage = 0 // 새 알은 처음부터 인큐베이션(재부화에 5M 필요) - state.eggTier = tier // 등급 보증(nil = 보증 없음) - state.pendingHatchID = 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: discarded active, tier=\(tier?.rawValue ?? "none")") - Task { await self.ensureEggPrefetch() } // 다음 부화 예열 + AppLog.write("egg purchased: parked active in box, tier=\(tier?.rawValue ?? "none")") + Task { await self.ensureEggPrefetch() } // pre-warm the next hatch save() return true } diff --git a/Sources/PokeTokenBar/Core/Localization.swift b/Sources/PokeTokenBar/Core/Localization.swift index 64854f4d..49d61126 100644 --- a/Sources/PokeTokenBar/Core/Localization.swift +++ b/Sources/PokeTokenBar/Core/Localization.swift @@ -658,6 +658,14 @@ 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 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") } @@ -958,22 +966,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("지금 포켓몬을 박스에 보관하고 새 알을 바로 품기 시작해요.", + "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) 이상이 확정으로 나오는 알을 받아요.", - "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) 이상 확정 알을 바로 품기 시작해요.", + "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 { @@ -991,13 +999,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?") + 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 828ba88c..c43b2664 100644 --- a/Sources/PokeTokenBar/Core/SaveTransfer.swift +++ b/Sources/PokeTokenBar/Core/SaveTransfer.swift @@ -142,6 +142,17 @@ 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)) + m.profile?.sanitize() + return m + } var s = state s.usedSinceInstall = clampToken(s.usedSinceInstall) s.spentTokens = clampToken(s.spentTokens) @@ -160,14 +171,11 @@ 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)) - active.profile?.sanitize() - 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. + // (clampMon also sanitizes each entry's profile.) + s.box = s.box.map(clampMon) for index in s.dex.indices { s.dex[index].profile?.sanitize() } s.reconcileRepresentativeSelection() return s @@ -176,8 +184,8 @@ enum SaveTransfer { /// 다른 기기에서 온 상태를 **이 기기 기준으로 재정렬**한다. /// /// `CompanionState` 의 필드는 이전 관점에서 세 부류다. - /// - **진행**: 어느 기기에서든 참(`usedSinceInstall`·`dex`·`inventory`·`active`·`eggUsage`·`eggTier`· - /// `representativeSpeciesID`…) + /// - **진행**: 어느 기기에서든 참(`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 d7d7c5f8..607d9e48 100644 --- a/Sources/PokeTokenBar/UI/CompanionView.swift +++ b/Sources/PokeTokenBar/UI/CompanionView.swift @@ -765,17 +765,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) } @@ -826,6 +842,173 @@ 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 + /// 개체별 현재 형태 이름(mon.id → 이름). 라인 조회가 필요해 비동기로 채운다(라인은 base 단위 캐시). + @State private var names: [String: String] = [:] + + 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) + } + .task(id: mons.map(\.id)) { names = await store.boxDisplayNames() } + } + } + + 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.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 { + 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 name: String? + 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(name ?? "#\(mon.currentID)") + .font(.system(size: 10, weight: .semibold)) + .lineLimit(1).minimumScaleFactor(0.8) + if mon.isComplete { + Text("🏆").font(.system(size: 11)) // 졸업 트로피 — 진행 바 자리에 표시. + } 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 54cd71f0..36813e02 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/Sources/PokeTokenBar/UI/ShopView.swift b/Sources/PokeTokenBar/UI/ShopView.swift index cb33399d..7a21ccc4 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 { store.price(of: .egg(tier)) } @@ -182,8 +182,7 @@ 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.canBuyEgg(tier) { @@ -204,28 +203,15 @@ private struct EggCard: View { Text(l.eggConfirm(store.displayName, l.eggName(tier))) .font(.caption2).foregroundStyle(.secondary).lineLimit(2) Spacer() - // 이로치면 한 번 더 경고, 아니면 즉시 실행. - Button(l.buy) { - if store.currentIsShiny { stage = .shinyConfirm } else { commit() } - } - .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 으로 전환(가방 사용과 동일 패턴). + /// 구매 실행 → 새로 품는 알을 볼 수 있게 Home 으로 전환(현재 개체는 박스에 보관됨). private func commit() { stage = .idle if store.buyEgg(tier) { nav.tab = .home } diff --git a/Tests/PokeTokenBarTests/CatchLogRenderingTests.swift b/Tests/PokeTokenBarTests/CatchLogRenderingTests.swift index 5d30785b..15eb6715 100644 --- a/Tests/PokeTokenBarTests/CatchLogRenderingTests.swift +++ b/Tests/PokeTokenBarTests/CatchLogRenderingTests.swift @@ -50,7 +50,7 @@ final class CatchLogRenderingTests: XCTestCase { for attempt in 1...3 { autoreleasepool { let navigation = PopoverNavigation() - navigation.showingCollectionLog = true + navigation.collectionSection = .log let before = image.sizeReadCount let host = NSHostingController(rootView: CollectionView(store: store, navigation: navigation) .frame(width: PopoverMetrics.contentWidth) diff --git a/Tests/PokeTokenBarTests/CompanionTests.swift b/Tests/PokeTokenBarTests/CompanionTests.swift index 7d26b4fe..1ecf4c74 100644 --- a/Tests/PokeTokenBarTests/CompanionTests.swift +++ b/Tests/PokeTokenBarTests/CompanionTests.swift @@ -202,6 +202,129 @@ 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 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,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)) + 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", "still active") + XCTAssertEqual(s.state.active?.usedAtStage, 0, "no growth accrued") + 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 + /// 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 { + 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·인벤토리를 지킨다 — @@ -620,47 +743,27 @@ final class CompanionStoreTests: XCTestCase { XCTAssertEqual(s.dexSpecies.map(\.isRaising), [false, true, false]) } - // MARK: 놓아줌 (알 구매로 포기한 개체의 영구 기록) + // MARK: Fresh egg (swap — active is parked in the box, not released; a new egg hatches) - /// [회귀·트리거] 3단 라인을 2단까지 키우다 놓아주면 **도달한 두 형태만** 남는다. - /// - /// 트리거 분기: `pathIDs` 는 실현 경로, `plannedPathIDs` 는 전체 계획이다. 놓아줌 기록에 - /// 계획을 쓰면 한 번도 본 적 없는 최종 진화형이 보유로 잡힌다 — 알을 사서 포기하는 것이 - /// 도감을 채우는 지름길이 된다. `dexSpecies` 가 육성 중 쓰는 prefix 규칙과 같아야 한다. - func testReleasingMidChainCreditsOnlyReachedForms() 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) 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 }, "미도달 진화형은 보유가 아니다") - } - - /// 위장 중인 메타몽을 놓아주면 이로치는 계속 숨겨진다 — `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) - 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 }, - fileURL: url, rng: SeededRNG(seed: 7)) - XCTAssertTrue(s.buyFreshEgg()) - let released = try XCTUnwrap(s.state.dex.last) - XCTAssertFalse(released.isShiny, "위장 중이면 리빌 전까지 숨김") + 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") } /// 이 필드 이전에 저장된 항목은 전부 졸업분으로 읽힌다 — 별도 마이그레이션 없이 nil = 졸업. @@ -1552,6 +1655,55 @@ 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") + 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] + + 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") + } + + /// [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..b7dca845 100644 --- a/Tests/PokeTokenBarTests/FreshEggTests.swift +++ b/Tests/PokeTokenBarTests/FreshEggTests.swift @@ -1,7 +1,7 @@ import XCTest @testable import PokeTokenBar -// MARK: 새 알 (리롤 — 현재 포켓몬 폐기, 도감·확률 무영향) +// 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) } @@ -13,12 +13,12 @@ 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") - 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,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 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 persistedDexBefore = s.state.dex - let collectedBefore = s.state.collectedFinals - XCTAssertEqual(s.dexEntries.count, persistedDexBefore.count + 1, - "현재 포켓몬은 졸업 전에도 도감 화면에 표시") + let dexCountBefore = s.state.dex.count XCTAssertTrue(s.hasActive) + XCTAssertTrue(s.buyFreshEgg()) - XCTAssertNil(s.state.active, "현재 포켓몬은 더 이상 활성이 아니다") + + XCTAssertNil(s.state.active, "active slot is now the fresh egg") XCTAssertTrue(s.isEgg) - XCTAssertEqual(s.state.eggUsage, 0, "새 알은 처음부터 인큐베이션") - XCTAssertNil(s.state.pendingHatchID) + 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.spentTokens, FreshEgg.price, "wallet charged 1B") + XCTAssertEqual(s.availableTokens, 5_000_000_000 - FreshEgg.price) + } - 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, - "합성 엔트리는 사라지고 영구 기록이 그 자리를 대신한다") + /// 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)) + XCTAssertNil(s.state.active) + XCTAssertEqual(s.state.eggTier, .uncommon) + XCTAssertEqual(s.state.spentTokens, FreshEgg.price(guaranteeing: .uncommon)) + } - XCTAssertEqual(s.state.collectedFinals, collectedBefore, "확률 가중(collectedFinals) 불변") - XCTAssertEqual(s.state.spentTokens, FreshEgg.price, "지갑에서 1B 차감") - XCTAssertEqual(s.availableTokens, 5_000_000_000 - FreshEgg.price) + /// 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.hasActive) + XCTAssertFalse(s.canBuyFreshEgg, "no active to park behind the next egg") + XCTAssertFalse(s.buyFreshEgg()) + XCTAssertEqual(s.state.spentTokens, FreshEgg.price, "charged only once") } - /// [회귀] 놓아준 종은 도감에서 사라지지 않는다. - /// - /// 이게 이 기능의 존재 이유다. 도감은 "쌓이기만 한다"는 약속을 주는데, 알 구매가 유일하게 - /// 그 약속을 깨는 경로였다 — 현재 개체에서만 오던 종이 통째로 빠져 종 수가 줄었다. - /// `state.dex` 에 남기는 대신 `state.active = nil` 만 하면 이 테스트가 실패한다(주입 확인함). - func testReleasedSpeciesStaysInTheDex() { + /// 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 }, "육성 중엔 도감에 보인다") + XCTAssertTrue(s.dexSpecies.contains { $0.id == 10 }, "raising → shown") 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 — parked in the box, not released") } - /// 폐기한 개체(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 parked species is not added to the collected set") } - /// 알 상태(활성 없음)에선 리롤할 게 없어 불가. - func testCannotRerollWhenEgg() { + /// 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) 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 — 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()) - XCTAssertNil(s.state.active) - XCTAssertFalse(s.currentIsShiny) + 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/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, "포획 로그에서 설정을 열었어도 대표 선택은 도감으로 이동") } } diff --git a/Tests/PokeTokenBarTests/PremiumEggTests.swift b/Tests/PokeTokenBarTests/PremiumEggTests.swift index 4f2c89a3..b5e564b2 100644 --- a/Tests/PokeTokenBarTests/PremiumEggTests.swift +++ b/Tests/PokeTokenBarTests/PremiumEggTests.swift @@ -173,19 +173,18 @@ final class PremiumEggTests: XCTestCase { // MARK: 구매 — 게이트·차감·보증 기록 - func testBuyPremiumEggRecordsGuaranteeAndDebitsTierPrice() { + func testBuyPremiumEggParksActiveAndSetsTier() { let s = activeStore() XCTAssertTrue(s.canBuyEgg(.rare)) XCTAssertTrue(s.buyEgg(.rare)) - XCTAssertEqual(s.state.eggTier, .rare, "보증이 상태에 기록됨") + 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)) - 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, "최종체 완성으로 세지 않는다") + 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) } /// 알 상태에선 등급 알도 못 산다 — 기존 새 알과 게이트 통일. @@ -216,13 +215,14 @@ final class PremiumEggTests: XCTestCase { /// (활성 포켓몬과 pre-roll 은 애초에 공존하지 않는다: 프리패치는 알 상태에서만 돌고 `hatchIfNeeded` /// 가 부화 직전 비운다. 그 불변식이 밖에서 들어온 파일에도 유지되는지는 /// `testSanitizedDropsGuaranteeAndItsPreRollWhenActiveExists` 가 지킨다.) - func testPurchaseStartsFromCleanRollState() { + func testPurchaseParksActiveAndStartsGuaranteedEgg() { 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.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") } /// 보증은 재시작을 건너 살아남아야 한다 — 구매 시점엔 종을 못 정하기 때문(롤에 네트워크 필요). @@ -231,7 +231,7 @@ final class PremiumEggTests: XCTestCase { 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.state.eggTier, .uncommon, "the purchased guarantee survives a restart") XCTAssertEqual(s2.eggGuarantee, .uncommon) } diff --git a/Tests/PokeTokenBarTests/SaveTransferTests.swift b/Tests/PokeTokenBarTests/SaveTransferTests.swift index 0be3fad2..a34d1a10 100644 --- a/Tests/PokeTokenBarTests/SaveTransferTests.swift +++ b/Tests/PokeTokenBarTests/SaveTransferTests.swift @@ -473,6 +473,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) @@ -486,6 +489,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") @@ -527,9 +534,10 @@ final class SaveTransferTests: XCTestCase { /// 딸려 들어간다(`language` 가 실제로 그랬다). 필드 목록을 테스트로 고정해 **분류를 강제**한다. func testEveryCompanionStateFieldIsClassifiedForTransfer() { // eggTier(알 등급 보증) = 진행 — 산 물건이지 이 기기의 장부가 아니라 기기를 옮겨도 따라간다. + // box(보관 개체) = 진행 — 수집물이라 기기를 옮겨도 따라간다. let progress: Set = ["usedSinceInstall", "spentTokens", "eggUsage", "eggTier", "pendingHatchID", "active", "representativeSpeciesID", "dex", - "collectedFinals", "inventory"] + "collectedFinals", "inventory", "box"] let deviceLedger: Set = ["installBaselineSet", "claimedTodayTokensByProvider", "lastDate"] let accountLedger: Set = ["candyGrantTier", "candyFeatureSeeded"] let devicePreference: Set = ["language"]