Skip to content
Closed
18 changes: 18 additions & 0 deletions Sources/PokeTokenBar/Core/CompanionModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] // 사전에 선택한 전체 진화 경로
Expand All @@ -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 {
Expand Down Expand Up @@ -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 가 기본(알)로 폴백되게 한다.
Expand Down Expand Up @@ -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) 쌍 — 분기 다양성용
Expand Down Expand Up @@ -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<MonState>].self, forKey: .box, default: []).compactMap(\.value)
// 도감은 항목별 격리 — 손상 항목 하나가 도감 전체를 날리지 않게.
dex = c.lenient([Lossy<DexEntry>].self, forKey: .dex, default: []).compactMap(\.value)
collectedFinals = c.lenient(Set<String>.self, forKey: .collectedFinals, default: [])
Expand All @@ -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)
}
Expand All @@ -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 }
Expand Down
133 changes: 76 additions & 57 deletions Sources/PokeTokenBar/Core/CompanionStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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) }
Expand Down Expand Up @@ -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
}
Expand Down
Loading