Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
203 changes: 198 additions & 5 deletions PokeTokenBarShared/Sources/PokeTokenBarShared/PhonePayload.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,17 @@ public struct PhonePayload: Codable, Sendable, Equatable {
public let monthCost: Double?
/// Claude 5h depletion forecast (nil when no forecast can be made).
public let burn: PhoneBurnForecast?
/// Catch log — individual caught mon records (read-only mirror of Mac catch log).
public let catchLog: [PhoneDexEntry]

public init(todayTokens: Int, todayCost: Double, weekTokens: Int, monthTokens: Int,
lastUpdated: Date, serverVersion: String, limits: PhoneLimitStatus?,
companion: PhoneCompanionState?, providers: [PhoneProviderSnapshot],
bag: [PhoneBagItem] = [], dex: [PhoneDexSpecies] = [],
spendableTokens: Int = 0, shop: [PhoneShopEntry] = [],
weekCost: Double? = nil, monthCost: Double? = nil,
burn: PhoneBurnForecast? = nil) {
burn: PhoneBurnForecast? = nil,
catchLog: [PhoneDexEntry] = []) {
self.todayTokens = todayTokens
self.todayCost = todayCost
self.weekTokens = weekTokens
Expand All @@ -50,9 +53,10 @@ public struct PhonePayload: Codable, Sendable, Equatable {
self.weekCost = weekCost
self.monthCost = monthCost
self.burn = burn
self.catchLog = catchLog
}

/// Older Mac versions publish payloads without `bag`/`dex`/`shop` — decode them
/// Older Mac versions publish payloads without `bag`/`dex`/`shop`/`catchLog` — decode them
/// as empty instead of failing the whole sync.
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
Expand All @@ -72,6 +76,7 @@ public struct PhonePayload: Codable, Sendable, Equatable {
weekCost = try c.decodeIfPresent(Double.self, forKey: .weekCost)
monthCost = try c.decodeIfPresent(Double.self, forKey: .monthCost)
burn = try c.decodeIfPresent(PhoneBurnForecast.self, forKey: .burn)
catchLog = try c.decodeIfPresent([PhoneDexEntry].self, forKey: .catchLog) ?? []
}
}

Expand Down Expand Up @@ -432,7 +437,7 @@ public struct PhoneBagItem: Codable, Sendable, Equatable, Identifiable {

/// One collected species — graduated records ∪ the current mon's reached stages.
/// Species-level only (nature/catch-time are Mac-side catch-log details).
public struct PhoneDexSpecies: Codable, Sendable, Equatable, Identifiable {
public struct PhoneDexSpecies: Codable, Sendable, Equatable, Identifiable, Hashable {
/// Species ID = national dex number (sort key).
public let id: Int
/// Pre-localized species name from the Mac.
Expand All @@ -443,13 +448,201 @@ public struct PhoneDexSpecies: Codable, Sendable, Equatable, Identifiable {
public let isShiny: Bool
/// The only evidence is the currently-raised mon — the cell can disappear.
public let isRaising: Bool

public init(id: Int, name: String, rarity: String, isShiny: Bool, isRaising: Bool) {
/// Whether this species is pinned as the representative companion.
public let isRepresentative: Bool?
/// Number of distinct Unown forms collected (only for Unown species #201).
public let unownFormCount: Int?
/// Collected Unown forms list (only for Unown species #201).
public let unownForms: [PhoneUnownForm]?
/// Extended PokéAPI species metadata pre-localized by the Mac.
public let details: PhoneSpeciesDetails?

public init(id: Int, name: String, rarity: String, isShiny: Bool, isRaising: Bool,
isRepresentative: Bool? = nil, unownFormCount: Int? = nil,
unownForms: [PhoneUnownForm]? = nil, details: PhoneSpeciesDetails? = nil) {
self.id = id
self.name = name
self.rarity = rarity
self.isShiny = isShiny
self.isRaising = isRaising
self.isRepresentative = isRepresentative
self.unownFormCount = unownFormCount
self.unownForms = unownForms
self.details = details
}

public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decode(Int.self, forKey: .id)
name = try c.decode(String.self, forKey: .name)
rarity = try c.decode(String.self, forKey: .rarity)
isShiny = try c.decode(Bool.self, forKey: .isShiny)
isRaising = try c.decode(Bool.self, forKey: .isRaising)
isRepresentative = try c.decodeIfPresent(Bool.self, forKey: .isRepresentative)
unownFormCount = try c.decodeIfPresent(Int.self, forKey: .unownFormCount)
unownForms = try c.decodeIfPresent([PhoneUnownForm].self, forKey: .unownForms)
details = try c.decodeIfPresent(PhoneSpeciesDetails.self, forKey: .details)
}
}

/// One collected Unown form.
public struct PhoneUnownForm: Codable, Sendable, Equatable, Identifiable, Hashable {
public var id: String { form }
public let form: String // "a", "b", ..., "question", "exclamation"
public let symbol: String // "A", "B", ..., "?", "!"
public let isShiny: Bool
public let isRepresentative: Bool

public init(form: String, symbol: String, isShiny: Bool, isRepresentative: Bool = false) {
self.form = form
self.symbol = symbol
self.isShiny = isShiny
self.isRepresentative = isRepresentative
}
}

/// Species battle & Pokedex metadata, pre-localized by the Mac.
public struct PhoneSpeciesDetails: Codable, Sendable, Equatable, Hashable {
public let types: [String]
public let height: Int // decimetres (4 -> 0.4 m)
public let weight: Int // hectograms (60 -> 6.0 kg)
public let baseStatTotal: Int
public let baseStats: [PhoneBaseStat]
public let possibleAbilities: [PhoneAbilityOption]
public let moveList: [PhoneMoveListing]

public init(types: [String], height: Int, weight: Int, baseStatTotal: Int,
baseStats: [PhoneBaseStat], possibleAbilities: [PhoneAbilityOption],
moveList: [PhoneMoveListing]) {
self.types = types
self.height = height
self.weight = weight
self.baseStatTotal = baseStatTotal
self.baseStats = baseStats
self.possibleAbilities = possibleAbilities
self.moveList = moveList
}
}

public struct PhoneBaseStat: Codable, Sendable, Equatable, Identifiable, Hashable {
public var id: String { name }
public let name: String
public let label: String
public let value: Int

public init(name: String, label: String, value: Int) {
self.name = name
self.label = label
self.value = value
}
}

public struct PhoneAbilityOption: Codable, Sendable, Equatable, Identifiable, Hashable {
public var id: String { name }
public let name: String
public let isHidden: Bool

public init(name: String, isHidden: Bool) {
self.name = name
self.isHidden = isHidden
}
}

public struct PhoneMoveListing: Codable, Sendable, Equatable, Identifiable, Hashable {
public var id: String { name }
public let name: String
public let methods: [String]

public init(name: String, methods: [String]) {
self.name = name
self.methods = methods
}
}

// MARK: - Catch Log (read-only)

/// One individual catch record from the Mac's catch log.
public struct PhoneDexEntry: Codable, Sendable, Equatable, Identifiable, Hashable {
public let id: String
public let baseID: Int
public let finalID: Int
public let rarity: String
public let isShiny: Bool
public let isRaising: Bool
public let isReleased: Bool
public let natureName: String?
public let caughtAt: Date?
public let chainOrder: [Int]
public let chainNames: [Int: String]
public let unownForm: String?
public let profile: PhoneIndividualProfile?

public init(id: String, baseID: Int, finalID: Int, rarity: String,
isShiny: Bool, isRaising: Bool, isReleased: Bool,
natureName: String?, caughtAt: Date?, chainOrder: [Int],
chainNames: [Int: String], unownForm: String?,
profile: PhoneIndividualProfile? = nil) {
self.id = id
self.baseID = baseID
self.finalID = finalID
self.rarity = rarity
self.isShiny = isShiny
self.isRaising = isRaising
self.isReleased = isReleased
self.natureName = natureName
self.caughtAt = caughtAt
self.chainOrder = chainOrder
self.chainNames = chainNames
self.unownForm = unownForm
self.profile = profile
}
}

public struct PhoneIndividualProfile: Codable, Sendable, Equatable, Hashable {
public let level: Int
public let gender: String?
public let genderLabel: String?
public let abilityName: String?
public let abilityIsHidden: Bool
public let stats: [PhoneComputedStat]
public let moves: [PhoneKnownMove]

public init(level: Int, gender: String?, genderLabel: String?,
abilityName: String?, abilityIsHidden: Bool,
stats: [PhoneComputedStat], moves: [PhoneKnownMove]) {
self.level = level
self.gender = gender
self.genderLabel = genderLabel
self.abilityName = abilityName
self.abilityIsHidden = abilityIsHidden
self.stats = stats
self.moves = moves
}
}

public struct PhoneComputedStat: Codable, Sendable, Equatable, Identifiable, Hashable {
public var id: String { name }
public let name: String
public let label: String
public let value: Int
public let iv: Int?

public init(name: String, label: String, value: Int, iv: Int?) {
self.name = name
self.label = label
self.value = value
self.iv = iv
}
}

public struct PhoneKnownMove: Codable, Sendable, Equatable, Identifiable, Hashable {
public var id: String { "\(name)-\(learnedAtLevel)" }
public let name: String
public let learnedAtLevel: Int

public init(name: String, learnedAtLevel: Int) {
self.name = name
self.learnedAtLevel = learnedAtLevel
}
}

Expand Down
34 changes: 28 additions & 6 deletions PokeTokenBarShared/Sources/PokeTokenBarShared/SpriteCache.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,48 @@ import Foundation
public enum PokeSpriteURL {
private static let base = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites"

public static func species(id: Int, shiny: Bool) -> URL? {
URL(string: "\(base)/pokemon/\(shiny ? "shiny/" : "")\(id).png")
public static func species(id: Int, shiny: Bool, unownForm: String? = nil) -> URL? {
let name: String
if id == 201, let form = unownForm, !form.isEmpty, form != "a" {
name = "\(id)-\(form)"
} else {
name = "\(id)"
}
return URL(string: "\(base)/pokemon/\(shiny ? "shiny/" : "")\(name).png")
}

/// Gen-V animated GIF — only species 1…649 have one (same rule as the Mac's PokemonAssets).
public static func hasAnimatedSprite(id: Int) -> Bool { (1...649).contains(id) }

public static func animatedSpecies(id: Int, shiny: Bool) -> URL? {
public static func animatedSpecies(id: Int, shiny: Bool, unownForm: String? = nil) -> URL? {
guard hasAnimatedSprite(id: id) else { return nil }
return URL(string: "\(base)/pokemon/versions/generation-v/black-white/animated/\(shiny ? "shiny/" : "")\(id).gif")
let name: String
if id == 201, let form = unownForm, !form.isEmpty, form != "a" {
name = "\(id)-\(form)"
} else {
name = "\(id)"
}
return URL(string: "\(base)/pokemon/versions/generation-v/black-white/animated/\(shiny ? "shiny/" : "")\(name).gif")
}

public static func animatedSpeciesKey(id: Int, shiny: Bool) -> String { "\(id)_\(shiny)_anim.gif" }
public static func animatedSpeciesKey(id: Int, shiny: Bool, unownForm: String? = nil) -> String {
if id == 201, let form = unownForm, !form.isEmpty, form != "a" {
return "\(id)_\(form)_\(shiny)_anim.gif"
}
return "\(id)_\(shiny)_anim.gif"
}

public static func item(name: String) -> URL? {
URL(string: "\(base)/items/\(name).png")
}

/// Stable on-disk file name for a species sprite — the same key the widget has always used.
public static func speciesKey(id: Int, shiny: Bool) -> String { "\(id)_\(shiny).png" }
public static func speciesKey(id: Int, shiny: Bool, unownForm: String? = nil) -> String {
if id == 201, let form = unownForm, !form.isEmpty, form != "a" {
return "\(id)_\(form)_\(shiny).png"
}
return "\(id)_\(shiny).png"
}
public static func itemKey(name: String) -> String { "item_\(name).png" }
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,87 @@ struct PhonePayloadTests {
#expect(decoded.dex.isEmpty)
#expect(decoded.shop.isEmpty)
#expect(decoded.spendableTokens == 0)
#expect(decoded.catchLog.isEmpty)
}

@Test func catchLogAndDexDetailsCodableRoundTrip() throws {
let details = PhoneSpeciesDetails(
types: ["electric"],
height: 4,
weight: 60,
baseStatTotal: 320,
baseStats: [
PhoneBaseStat(name: "hp", label: "HP", value: 35),
PhoneBaseStat(name: "attack", label: "Attack", value: 55)
],
possibleAbilities: [
PhoneAbilityOption(name: "Static", isHidden: false),
PhoneAbilityOption(name: "Lightning Rod", isHidden: true)
],
moveList: [
PhoneMoveListing(name: "Thunder Shock", methods: ["Lv. 1"]),
PhoneMoveListing(name: "Thunderbolt", methods: ["TM"])
]
)
let unownForm = PhoneUnownForm(form: "b", symbol: "B", isShiny: true, isRepresentative: true)
let species = PhoneDexSpecies(
id: 201, name: "Unown", rarity: "rare", isShiny: true, isRaising: false,
isRepresentative: true, unownFormCount: 5, unownForms: [unownForm],
details: details
)
let entry = PhoneDexEntry(
id: "entry-1", baseID: 172, finalID: 25, rarity: "common",
isShiny: true, isRaising: true, isReleased: false,
natureName: "Adamant", caughtAt: Date(timeIntervalSince1970: 1700000000),
chainOrder: [172, 25, 26],
chainNames: [172: "Pichu", 25: "Pikachu", 26: "Raichu"],
unownForm: nil,
profile: PhoneIndividualProfile(
level: 25, gender: "male", genderLabel: "Male",
abilityName: "Static", abilityIsHidden: false,
stats: [PhoneComputedStat(name: "hp", label: "HP", value: 58, iv: 31)],
moves: [PhoneKnownMove(name: "Thunder Shock", learnedAtLevel: 1)]
)
)

let payload = PhonePayload(
todayTokens: 100, todayCost: 1.0, weekTokens: 200, monthTokens: 300,
lastUpdated: Date(timeIntervalSince1970: 1700000000), serverVersion: "2.6.0",
limits: nil, companion: nil, providers: [],
dex: [species], catchLog: [entry]
)

let data = try JSONEncoder().encode(payload)
let decoded = try JSONDecoder().decode(PhonePayload.self, from: data)
#expect(decoded.dex.count == 1)
#expect(decoded.dex.first?.isRepresentative == true)
#expect(decoded.dex.first?.unownFormCount == 5)
#expect(decoded.dex.first?.unownForms?.first?.symbol == "B")
#expect(decoded.dex.first?.details?.baseStatTotal == 320)
#expect(decoded.catchLog.count == 1)
#expect(decoded.catchLog.first?.natureName == "Adamant")
#expect(decoded.catchLog.first?.chainOrder == [172, 25, 26])
#expect(decoded.catchLog.first?.profile?.level == 25)
#expect(decoded.catchLog.first?.profile?.stats.first?.iv == 31)
}

@Test func pokeSpriteURLUnownForm() {
// Unown default A uses standard 201.png
let defaultURL = PokeSpriteURL.species(id: 201, shiny: false, unownForm: "a")
#expect(defaultURL?.absoluteString.hasSuffix("/pokemon/201.png") == true)

// Non-A forms use 201-{form}.png
let bURL = PokeSpriteURL.species(id: 201, shiny: true, unownForm: "b")
#expect(bURL?.absoluteString.hasSuffix("/pokemon/shiny/201-b.png") == true)
#expect(PokeSpriteURL.speciesKey(id: 201, shiny: true, unownForm: "b") == "201_b_true.png")

// Exclamation form
let exURL = PokeSpriteURL.species(id: 201, shiny: false, unownForm: "exclamation")
#expect(exURL?.absoluteString.hasSuffix("/pokemon/201-exclamation.png") == true)

// Other species ignore unownForm
let pikaURL = PokeSpriteURL.species(id: 25, shiny: false, unownForm: "b")
#expect(pikaURL?.absoluteString.hasSuffix("/pokemon/25.png") == true)
}

// MARK: - CloudKitSync
Expand Down
Loading