Skip to content
45 changes: 45 additions & 0 deletions Sources/PokeTokenBar/Core/CompanionStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@ final class CompanionStore {
private(set) var pokemonDetailsByID: [Int: PokemonDetails] = [:]
private(set) var loadingPokemonDetailIDs: Set<Int> = []
private(set) var failedPokemonDetailIDs: Set<Int> = []
private let flavorProvider: (any PokemonFlavorTextProviding)?
/// Keyed by species *and* language: switching languages never shows the other language's entries.
private(set) var flavorTextsByRequest: [FlavorTextRequest: DexEntries] = [:]
private(set) var failedFlavorTextRequests: Set<FlavorTextRequest> = []
/// Store-owned so a detail page closing mid-request does not cancel it: reopening joins the
/// same request instead of finding it "in flight" and waiting on one that never lands.
private var flavorTextTasks: [FlavorTextRequest: Task<Void, Never>] = [:]
private let defaults: UserDefaults
/// 세션 내 활성 개체 교체 감지용. await 뒤 이전 개체의 결과가 새 개체를 덮지 않게 한다.
private var activeGeneration = 0
Expand All @@ -67,13 +74,15 @@ final class CompanionStore {

init(provider: any PokeProviding = PokeAPIClient.shared,
detailProvider: (any PokemonDetailProviding)? = nil,
flavorProvider: (any PokemonFlavorTextProviding)? = nil,
clock: @escaping () -> Date = Date.init,
fileURL: URL? = nil,
rng: any RandomNumberGenerator = SystemRandomNumberGenerator(),
dittoDisguiseRollingEnabled: Bool = AppEnv.isBundledApp,
defaults: UserDefaults = .standard) {
self.provider = provider
self.detailProvider = detailProvider ?? (provider as? any PokemonDetailProviding)
self.flavorProvider = flavorProvider ?? (provider as? any PokemonFlavorTextProviding)
self.clock = clock
self.fileURL = fileURL ?? Self.defaultURL()
self.rng = rng
Expand Down Expand Up @@ -2010,6 +2019,42 @@ final class CompanionStore {
}
}

/// The request the detail page shows: the species in the current app language.
func flavorTextRequest(speciesID: Int) -> FlavorTextRequest {
FlavorTextRequest(speciesID: speciesID, language: state.language)
}

func isLoadingFlavorTexts(_ request: FlavorTextRequest) -> Bool { flavorTextTasks[request] != nil }

/// Loads the species' Pokédex entries in the current language, independently of battle details.
///
/// The request outlives the page that started it, so the language it was made for is captured
/// before the first await and checked again before success *or* failure is stored. A degraded
/// (REST) result stays visible but is fetched again on the next visit.
func loadFlavorTexts(speciesID: Int) async {
let request = flavorTextRequest(speciesID: speciesID)
if let running = flavorTextTasks[request] { return await running.value }
if let loaded = flavorTextsByRequest[request], !loaded.isDegraded { return }
guard let flavorProvider else { return }
failedFlavorTextRequests.remove(request)
let task = Task {
defer { flavorTextTasks[request] = nil }
do {
let entries = try await flavorProvider.flavorTexts(speciesID: request.speciesID,
language: request.language)
guard state.language == request.language else { return }
flavorTextsByRequest[request] = entries
} catch {
guard state.language == request.language else { return }
failedFlavorTextRequests.insert(request)
AppLog.write("dex flavor text fetch failed id=\(request.speciesID) " +
"lang=\(request.language.rawValue): \(error)")
}
}
flavorTextTasks[request] = task
await task.value
}

/// Startup/background warmup for the only profile needed before a detail page is opened.
func preparePokemonProfiles() async {
guard let speciesID = state.active?.currentID else { return }
Expand Down
7 changes: 7 additions & 0 deletions Sources/PokeTokenBar/Core/Localization.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1157,6 +1157,13 @@ struct L {
var weight: String { t("몸무게", "Weight", "重さ", "Peso", "Poids", "Peso", "Gewicht", "Вес") }
var baseStatTotal: String { t("합계", "Base total", "合計", "Total base", "Total de base", "Total base", "Basiswertsumme", "Сумма базовых") }
var possibleAbilities: String { t("가능한 특성", "Possible abilities", "可能な特性", "Habilidades posibles", "Talents possibles", "Habilidades possíveis", "Mögliche Fähigkeiten", "Возможные способности") }
var dexFlavorTitle: String { t("도감 설명", "Pokédex entries", "図鑑説明", "Descripciones de la Pokédex", "Descriptions du Pokédex", "Descrições da Pokédex", "Pokédex-Einträge", "Записи Покедекса") }
var dexFlavorShowAll: String { t("도감 설명 전체 보기", "All Pokédex entries", "図鑑説明をすべて見る", "Ver todas las descripciones", "Voir toutes les descriptions", "Ver todas as descrições", "Alle Pokédex-Einträge", "Все записи Покедекса") }
var dexFlavorLoading: String { t("도감 설명 불러오는 중…", "Loading Pokédex entries…", "図鑑説明を読み込み中…", "Cargando descripciones de la Pokédex…", "Chargement des descriptions du Pokédex…", "Carregando descrições da Pokédex…", "Pokédex-Einträge werden geladen…", "Загрузка записей Покедекса…") }
var dexFlavorFailed: String { t("도감 설명을 불러오지 못했어요", "Couldn't load Pokédex entries", "図鑑説明を読み込めませんでした", "No se pudieron cargar las descripciones de la Pokédex", "Impossible de charger les descriptions du Pokédex", "Não foi possível carregar as descrições da Pokédex", "Pokédex-Einträge konnten nicht geladen werden", "Не удалось загрузить записи Покедекса") }
/// PokéAPI has no entries in this language at all (e.g. `pt`, `ru`); species names fall back the same way.
var dexFlavorEnglishFallback: String { t("이 언어로 제공되는 설명이 없어 영어로 표시합니다", "No entries in this language — showing English", "この言語の説明がないため英語で表示します", "No hay descripciones en este idioma — se muestra en inglés", "Aucune description dans cette langue — affichage en anglais", "Sem descrições neste idioma — exibindo em inglês", "Keine Einträge in dieser Sprache — Anzeige auf Englisch", "Записей на этом языке нет — показаны на английском") }
var dexFlavorEmpty: String { t("도감 설명이 없어요", "No Pokédex entries", "図鑑説明がありません", "No hay descripciones de la Pokédex", "Aucune description du Pokédex", "Sem descrições da Pokédex", "Keine Pokédex-Einträge", "Нет записей Покедекса") }
func completeMoveList(_ count: Int) -> String { t("전체 기술 목록 \(count)개", "Complete move list · \(count)", "全技リスト・\(count)", "Lista completa · \(count)", "Liste complète · \(count)", "Lista completa · \(count)", "Vollständige Attackenliste · \(count)", "Все приёмы · \(count)") }
func genderLabel(_ gender: PokemonGender?) -> String {
switch gender {
Expand Down
8 changes: 7 additions & 1 deletion Sources/PokeTokenBar/Core/PokeAPIClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ protocol PokemonDetailProviding: Sendable {

/// PokéAPI 클라이언트 — 종/진화체인을 런타임 fetch + 파싱. 포켓몬 데이터는 레포에 번들하지 않는다.
/// species 응답은 actor 캐시(다국어 이름 재사용).
actor PokeAPIClient: PokeProviding, PokemonDetailProviding {
actor PokeAPIClient: PokeProviding, PokemonDetailProviding, PokemonFlavorTextProviding {
static let shared = PokeAPIClient()
private let base = URL(string: "https://pokeapi.co/api/v2")!
static var langCodes: [String] { AppLanguage.allCases.flatMap(\.apiCodes) }
Expand Down Expand Up @@ -81,6 +81,12 @@ actor PokeAPIClient: PokeProviding, PokemonDetailProviding {
}
}

/// Lets `CompanionStore` pick up flavor text from its default provider the same way it picks up
/// details, while stub providers in tests stay offline.
func flavorTexts(speciesID: Int, language: AppLanguage) async throws -> DexEntries {
try await PokemonFlavorTextClient.shared.flavorTexts(speciesID: speciesID, language: language)
}

/// Reduce PokéAPI's cross-generation move history at the trust boundary, before it reaches
/// either memory or disk. Moves absent from the supported version group are omitted entirely.
static func normalizedMoves(_ moves: [PokemonMoveDTO]) -> [PokemonMoveOption] {
Expand Down
205 changes: 205 additions & 0 deletions Sources/PokeTokenBar/Core/PokemonFlavorText.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import Foundation

/// One Pokédex entry: the game version it appeared in and its text.
struct DexFlavorText: Sendable, Equatable, Identifiable {
/// PokéAPI `version.name` — stable across languages and refetches.
let versionKey: String
/// PokéAPI `version.id`, which follows release order.
let versionID: Int
/// Localized version name, or the formatted slug when none is available.
let versionLabel: String
/// One paragraph, without the line breaks that fit the games' text boxes.
let text: String
var id: String { versionKey }
}

/// The entries for one species and the language that actually filled them.
struct DexEntries: Sendable, Equatable {
/// Oldest version first, newest last.
let entries: [DexFlavorText]
/// English when the requested language has no entries at all (PokéAPI has no `pt`).
let language: AppLanguage
/// Served by the REST fallback, whose version labels are slugs. Shown, but never treated as final.
var isDegraded = false
}

/// Flavor text is the first per-language PokéAPI request: everything else caches every language at
/// once. A response therefore belongs to a species *and* a language and must never land under another.
struct FlavorTextRequest: Hashable, Sendable {
let speciesID: Int
let language: AppLanguage
}

/// Separate from `PokeProviding` so existing evolution-only test doubles stay small.
protocol PokemonFlavorTextProviding: Sendable {
func flavorTexts(speciesID: Int, language: AppLanguage) async throws -> DexEntries
}

/// A source-independent entry (GraphQL row or REST entry) before collapsing.
struct FlavorRow: Sendable {
let versionKey: String
let versionID: Int
let languageCode: String
let text: String
/// Language code → localized version name. Empty on the REST fallback.
let labels: [String: String]
}

/// Version-specific Pokédex entries. One GraphQL query returns the entries and the localized version
/// names. Any GraphQL failure — transport, HTTP status, an `errors` payload without `data`, or a body
/// that does not decode — falls back to REST, whose slug-labelled result is never cached.
actor PokemonFlavorTextClient: PokemonFlavorTextProviding {
static let shared = PokemonFlavorTextClient()

private static let graphQLURL = URL(string: "https://graphql.pokeapi.co/v1beta2")!
private let fetch: @Sendable (URLRequest) async throws -> (Data, Int)
private var cache: [FlavorTextRequest: DexEntries] = [:]

init(fetch: @escaping @Sendable (URLRequest) async throws -> (Data, Int) = PokemonFlavorTextClient.download) {
self.fetch = fetch
}

static func download(_ request: URLRequest) async throws -> (Data, Int) {
let (data, response) = try await URLSession.shared.data(for: request)
return (data, (response as? HTTPURLResponse)?.statusCode ?? 0)
}

func flavorTexts(speciesID: Int, language: AppLanguage) async throws -> DexEntries {
let request = FlavorTextRequest(speciesID: speciesID, language: language)
if let cached = cache[request] { return cached }
var result = try await load(speciesID: speciesID, language: language)
if Self.needsEnglishFallback(entries: result.entries, language: language) {
// An empty REST answer is as conclusive as an empty GraphQL one (REST carries every
// language), so only the English labels decide whether the result is degraded.
result = try await load(speciesID: speciesID, language: .en)
}
if !result.isDegraded { cache[request] = result }
return result
}

/// Falls back only when the requested language has nothing at all. A version missing in that
/// language is dropped instead — filling it from English would mix two languages on one card.
/// English has nowhere further to go.
static func needsEnglishFallback(entries: [DexFlavorText], language: AppLanguage) -> Bool {
entries.isEmpty && language != .en
}

private func load(speciesID: Int, language: AppLanguage) async throws -> DexEntries {
do {
var request = URLRequest(url: Self.graphQLURL)
request.httpMethod = "POST"
request.timeoutInterval = 15
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONSerialization.data(
withJSONObject: ["query": Self.query(speciesID: speciesID, language: language)])
let (data, status) = try await fetch(request)
return DexEntries(entries: try Self.parseGraphQL(data, status: status, language: language),
language: language)
} catch {
AppLog.write("dex flavor text (GraphQL) failed for #\(speciesID) — REST fallback: \(error)")
var request = URLRequest(url: URL(string: "https://pokeapi.co/api/v2/pokemon-species/\(speciesID)")!)
request.timeoutInterval = 15
let (data, status) = try await fetch(request)
return DexEntries(entries: try Self.parseREST(data, status: status, language: language),
language: language, isDegraded: true)
}
}

/// Filtered to the requested language server-side, with version names joined in the same query.
static func query(speciesID: Int, language: AppLanguage) -> String {
let codes = language.apiCodes.map { "\"\($0)\"" }.joined(separator: ",")
return """
{ pokemonspeciesflavortext(where: {pokemon_species_id: {_eq: \(speciesID)}, \
language: {name: {_in: [\(codes)]}}}, order_by: {version_id: asc}) \
{ flavor_text language { name } \
version { id name versionnames(where: {language: {name: {_in: [\(codes)]}}}) { name language { name } } } } }
"""
}

private struct GraphQLResponse: Decodable {
struct Language: Decodable { let name: String }
struct VersionName: Decodable {
let name: String
let language: Language
}
struct Version: Decodable {
let id: Int
let name: String
let versionnames: [VersionName]
}
struct Row: Decodable {
let flavor_text: String
let language: Language
let version: Version
}
struct DataBox: Decodable { let pokemonspeciesflavortext: [Row] }
let data: DataBox
}

static func parseGraphQL(_ data: Data, status: Int, language: AppLanguage) throws -> [DexFlavorText] {
guard status == 200 else { throw URLError(.badServerResponse) }
let rows = try JSONDecoder().decode(GraphQLResponse.self, from: data).data.pokemonspeciesflavortext.map {
FlavorRow(versionKey: $0.version.name, versionID: $0.version.id, languageCode: $0.language.name,
text: $0.flavor_text,
labels: $0.version.versionnames.reduce(into: [:]) { $0[$1.language.name] = $1.name })
}
return collapse(rows, language: language)
}

private struct SpeciesFlavorDTO: Decodable {
/// Unlike `NamedRef`, the URL is required: it is the only source of the version's release order.
struct Ref: Decodable {
let name: String
let url: String
}
struct Entry: Decodable {
let flavor_text: String
let language: Ref
let version: Ref
}
let flavor_text_entries: [Entry]
}

/// Decoded on its own rather than through `SpeciesDTO`, so the species cache that every hatch
/// and name backfill fills does not also hold every language's entries.
static func parseREST(_ data: Data, status: Int, language: AppLanguage) throws -> [DexFlavorText] {
guard status == 200 else { throw URLError(.badServerResponse) }
let rows = try JSONDecoder().decode(SpeciesFlavorDTO.self, from: data).flavor_text_entries.map {
FlavorRow(versionKey: $0.version.name, versionID: PokeAPIClient.id(from: $0.version.url),
languageCode: $0.language.name, text: $0.flavor_text, labels: [:])
}
return collapse(rows, language: language)
}

/// One entry per version, oldest first. Japanese arrives twice (`ja-hrkt`, `ja`); within a version
/// the earlier `apiCodes` candidate wins whatever order the rows came in.
static func collapse(_ rows: [FlavorRow], language: AppLanguage) -> [DexFlavorText] {
let codes = language.apiCodes.map(PokemonNameLocalization.languageCode)
var best: [String: (rank: Int, row: FlavorRow)] = [:]
for row in rows {
// The REST body carries every language; drop the ones that were not requested.
guard let rank = codes.firstIndex(of: PokemonNameLocalization.languageCode(row.languageCode)) else { continue }
if let current = best[row.versionKey], current.rank <= rank { continue }
best[row.versionKey] = (rank, row)
}
return best.values.map(\.row).sorted { $0.versionID < $1.versionID }.map { row in
let labels = row.labels.reduce(into: [String: String]()) {
$0[PokemonNameLocalization.languageCode($1.key)] = $1.value
}
return DexFlavorText(versionKey: row.versionKey, versionID: row.versionID,
versionLabel: codes.lazy.compactMap { labels[$0] }.first
?? PokemonNameLocalization.identifier(row.versionKey),
text: sanitize(row.text))
}
}

/// Line and page breaks only fit the games' text boxes, so they become spaces; soft hyphens go.
/// Japanese ideographic spaces (U+3000) are intended spacing and stay.
static func sanitize(_ raw: String) -> String {
var text = raw.replacingOccurrences(of: "\u{00AD}", with: "")
for control in ["\n", "\r", "\u{000C}"] {
text = text.replacingOccurrences(of: control, with: " ")
}
return text.split(separator: " ", omittingEmptySubsequences: true).joined(separator: " ")
}
}
Loading
Loading