From 50ae904730e608d8e948dc4997b5f3c5ad50ef69 Mon Sep 17 00:00:00 2001 From: "Taeyang Jin (Theo)" Date: Fri, 25 Sep 2026 10:42:14 +0900 Subject: [PATCH] feat: pick Korean particles to match the name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Korean copy wrote both particle forms after runtime Pokémon names ("유니란이(가)", "듀란(으)로") because the name is not known when the string is translated. Pick the particle from the final syllable's coda instead, with ㄹ taking "로". - Add KoreanParticle (subject 이/가, object 을/를, direction 으로/로). Names that do not end in a Hangul syllable, such as Unown's "[A]" or a "#123" placeholder, keep the combined form. - Use it in the six Korean strings that wrote both forms. Other languages are unchanged. - Add the helper to the coverage gate's logic core. No new dependency: es-hangul is JavaScript, and the check is a few lines of Unicode arithmetic. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../PokeTokenBar/Core/KoreanParticle.swift | 39 +++++++++++++++ Sources/PokeTokenBar/Core/Localization.swift | 10 ++-- .../KoreanParticleTests.swift | 48 +++++++++++++++++++ scripts/test-gate.sh | 1 + 4 files changed, 93 insertions(+), 5 deletions(-) create mode 100644 Sources/PokeTokenBar/Core/KoreanParticle.swift create mode 100644 Tests/PokeTokenBarTests/KoreanParticleTests.swift diff --git a/Sources/PokeTokenBar/Core/KoreanParticle.swift b/Sources/PokeTokenBar/Core/KoreanParticle.swift new file mode 100644 index 000000000..17b44562b --- /dev/null +++ b/Sources/PokeTokenBar/Core/KoreanParticle.swift @@ -0,0 +1,39 @@ +import Foundation + +/// 한국어 조사 선택 — 런타임에 오는 이름(PokéAPI) 뒤에 받침에 맞는 조사를 붙인다. +/// 번역문에 "이(가)"처럼 병기하던 자리를 "피카츄가", "이상해씨가"처럼 자연스럽게 만든다. +/// +/// 판정은 마지막 글자가 한글 음절(U+AC00…U+D7A3)일 때만 한다. 그 밖(안농의 "[A]", 이름 로딩 전 +/// "#123" 등)은 받침을 알 수 없으므로 기존 병기 형태를 그대로 붙인다 — 틀린 조사보다 병기가 낫다. +enum KoreanParticle { + case subject // 이/가 + case object // 을/를 + case direction // 으로/로 + + func attach(to word: String) -> String { + guard let coda = Self.coda(of: word) else { return word + fallback } + switch self { + case .subject: return word + (coda == 0 ? "가" : "이") + case .object: return word + (coda == 0 ? "를" : "을") + // ㄹ 받침은 "으로"가 아니라 "로"(서울로·알로). + case .direction: return word + (coda == 0 || coda == Self.rieul ? "로" : "으로") + } + } + + private var fallback: String { + switch self { + case .subject: return "이(가)" + case .object: return "을(를)" + case .direction: return "(으)로" + } + } + + private static let syllables: ClosedRange = 0xAC00...0xD7A3 + private static let rieul: UInt32 = 8 + + /// 마지막 글자의 종성 인덱스(0 = 받침 없음). 한글 음절이 아니면 nil. + private static func coda(of word: String) -> UInt32? { + guard let last = word.unicodeScalars.last, syllables.contains(last.value) else { return nil } + return (last.value - syllables.lowerBound) % 28 + } +} diff --git a/Sources/PokeTokenBar/Core/Localization.swift b/Sources/PokeTokenBar/Core/Localization.swift index b15e39169..f727e3a4a 100644 --- a/Sources/PokeTokenBar/Core/Localization.swift +++ b/Sources/PokeTokenBar/Core/Localization.swift @@ -841,14 +841,14 @@ struct L { var statusFocus: String { t("지금은 집중 모드예요.", "In focus mode now.", "今は集中モードです。", "Ahora está en modo concentración.", "En mode concentration.", "Agora está em modo foco.", "Gerade voll konzentriert.") } var statusTired: String { t("한도에 가까워요. 잠깐 쉬어도 괜찮아요.", "Close to the limit. A short break is fine.", "上限が近いです。少し休んでも大丈夫。", "Está cerca del límite. Un pequeño descanso no vendría mal.", "Proche de la limite. Une petite pause ne fait pas de mal.", "Está perto do limite. Uma pausa cai bem.", "Fast am Limit. Eine kurze Pause tut gut.") } var statusSleep: String { t("지금은 자고 있어요.", "Sleeping now.", "今は眠っています。", "Ahora está durmiendo.", "En train de dormir.", "Agora está dormindo.", "Schläft gerade.") } - func statusEvolved(_ name: String) -> String { t("\(name)(으)로 진화했어요!", "Evolved into \(name)!", "\(name) に進化しました!", "¡Evolucionó a \(name)!", "A évolué en \(name) !", "Evoluiu para \(name)!", "Hat sich zu \(name) entwickelt!") } + func statusEvolved(_ name: String) -> String { t("\(KoreanParticle.direction.attach(to: name)) 진화했어요!", "Evolved into \(name)!", "\(name) に進化しました!", "¡Evolucionó a \(name)!", "A évolué en \(name) !", "Evoluiu para \(name)!", "Hat sich zu \(name) entwickelt!") } var statusGrew: String { t("성장했어요!", "It grew!", "成長しました!", "¡Ha crecido!", "Il a grandi !", "Cresceu!", "Ist gewachsen!") } // MARK: companion 이벤트 시스템 알림 var notifHatchTitle: String { t("🥚 부화!", "🥚 Hatched!", "🥚 孵化!", "🥚 ¡Eclosionó!", "🥚 Éclosion !", "🥚 Chocou!", "🥚 Geschlüpft!") } - func notifHatchBody(_ name: String) -> String { t("알에서 \(name)이(가) 나왔어요!", "\(name) hatched from the egg!", "タマゴから \(name) が生まれました!", "¡\(name) salió del huevo!", "\(name) est sorti de l'œuf !", "\(name) saiu do ovo!", "\(name) ist aus dem Ei geschlüpft!") } + func notifHatchBody(_ name: String) -> String { t("알에서 \(KoreanParticle.subject.attach(to: name)) 나왔어요!", "\(name) hatched from the egg!", "タマゴから \(name) が生まれました!", "¡\(name) salió del huevo!", "\(name) est sorti de l'œuf !", "\(name) saiu do ovo!", "\(name) ist aus dem Ei geschlüpft!") } var notifShinyHatchTitle: String { t("✨ 이로치 포켓몬!", "✨ Shiny Pokémon!", "✨ 色違いポケモン!", "✨ ¡Pokémon variocolor!", "✨ Pokémon chromatique !", "✨ Pokémon shiny!", "✨ Schillerndes Pokémon!") } - func notifShinyHatchBody(_ name: String) -> String { t("이로치 \(name)이(가) 태어났어요! (1/64)", "A shiny \(name) hatched! (1 in 64)", "色違いの \(name) が生まれました!(1/64)", "¡Nació un \(name) variocolor! (1 entre 64)", "Un \(name) chromatique est né ! (1 sur 64)", "Nasceu um \(name) shiny! (1 em 64)", "Ein schillerndes \(name) ist geschlüpft! (1/64)") } + func notifShinyHatchBody(_ name: String) -> String { t("이로치 \(KoreanParticle.subject.attach(to: name)) 태어났어요! (1/64)", "A shiny \(name) hatched! (1 in 64)", "色違いの \(name) が生まれました!(1/64)", "¡Nació un \(name) variocolor! (1 entre 64)", "Un \(name) chromatique est né ! (1 sur 64)", "Nasceu um \(name) shiny! (1 em 64)", "Ein schillerndes \(name) ist geschlüpft! (1/64)") } var eggImminent: String { t("곧 부화해요!", "About to hatch!", "もうすぐ孵化!", "¡Está a punto de eclosionar!", "Sur le point d'éclore !", "Está quase chocando!", "Schlüpft gleich!") } /// 첫 실행(아직 토큰 적립 0) 안내 — "왜 아무 일도 안 일어나지"를 방지. var eggFirstRunHint: String { @@ -860,7 +860,7 @@ struct L { "Cresce com o uso das suas ferramentas locais de programação com IA. O ovo choca depois de uns 5M de tokens.", "Wächst mit der Nutzung deiner lokalen KI-Coding-Tools. Nach etwa 5M Tokens schlüpft dein Ei.") } var notifEvolveTitle: String { t("✨ 진화!", "✨ Evolved!", "✨ 進化!", "✨ ¡Evolucionó!", "✨ Évolution !", "✨ Evoluiu!", "✨ Entwicklung!") } - func notifEvolveBody(_ name: String) -> String { t("\(name)(으)로 진화했어요!", "Evolved into \(name)!", "\(name) に進化しました!", "¡Evolucionó a \(name)!", "A évolué en \(name) !", "Evoluiu para \(name)!", "Hat sich zu \(name) entwickelt!") } + func notifEvolveBody(_ name: String) -> String { t("\(KoreanParticle.direction.attach(to: name)) 진화했어요!", "Evolved into \(name)!", "\(name) に進化しました!", "¡Evolucionó a \(name)!", "A évolué en \(name) !", "Evoluiu para \(name)!", "Hat sich zu \(name) entwickelt!") } // 메타몽 위장 리빌 — 진화 못 하는 메타몽이 첫 진화 순간 정체를 드러낸다. var notifDittoRevealTitle: String { t("🎭 어라? 메타몽!", "🎭 Huh? It's Ditto!", "🎭 あれ?メタモン!", "🎭 ¿Eh? ¡Es Ditto!", "🎭 Hein ? C'est Métamorph !", "🎭 Ué? É um Ditto!", "🎭 Huch? Ditto!") } func notifDittoRevealBody(_ disguise: String) -> String { t("\(disguise)인 줄 알았는데 — 사실은 메타몽이었어요!", "You thought it was \(disguise) — it was Ditto all along!", "\(disguise) だと思ってた… 実はメタモンでした!", "Pensabas que era \(disguise) — ¡en realidad era Ditto!", "Tu croyais que c'était \(disguise) — c'était Métamorph depuis le début !", "Você achava que era \(disguise) — era um Ditto o tempo todo!", "Du dachtest, es wäre \(disguise) – dabei war es die ganze Zeit Ditto!") } @@ -1192,7 +1192,7 @@ 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)(으)로 바꿀까요?", + t("\(KoreanParticle.object.attach(to: monName)) 놓아주고 \(KoreanParticle.direction.attach(to: eggName)) 바꿀까요?", "Send off \(monName) for the \(eggName)?", "\(monName) を手放して \(eggName) にしますか?", "¿Soltar a \(monName) y cambiarlo por \(eggName)?", diff --git a/Tests/PokeTokenBarTests/KoreanParticleTests.swift b/Tests/PokeTokenBarTests/KoreanParticleTests.swift new file mode 100644 index 000000000..6d1018e82 --- /dev/null +++ b/Tests/PokeTokenBarTests/KoreanParticleTests.swift @@ -0,0 +1,48 @@ +import XCTest +@testable import PokeTokenBar + +/// 한국어 조사 선택 — 이름의 마지막 음절 받침으로 고르고, 판정할 수 없으면 병기 형태를 유지한다. +final class KoreanParticleTests: XCTestCase { + + func testSubjectFollowsFinalConsonant() { + XCTAssertEqual(KoreanParticle.subject.attach(to: "유니란"), "유니란이") + XCTAssertEqual(KoreanParticle.subject.attach(to: "피카츄"), "피카츄가") + } + + func testObjectFollowsFinalConsonant() { + XCTAssertEqual(KoreanParticle.object.attach(to: "꼬부기"), "꼬부기를") + XCTAssertEqual(KoreanParticle.object.attach(to: "리자몽"), "리자몽을") + } + + /// "으로/로"는 받침 없음과 ㄹ 받침이 같은 쪽이다 — 이 분기가 빠지면 "알으로"가 된다. + func testDirectionTreatsRieulLikeNoFinalConsonant() { + XCTAssertEqual(KoreanParticle.direction.attach(to: "란쿨루스"), "란쿨루스로") + XCTAssertEqual(KoreanParticle.direction.attach(to: "듀란"), "듀란으로") + XCTAssertEqual(KoreanParticle.direction.attach(to: "희귀 알"), "희귀 알로") + XCTAssertEqual(KoreanParticle.direction.attach(to: "레트라"), "레트라로") + } + + /// 받침을 알 수 없는 끝(안농 글자, 이름 로딩 전 번호, 라틴 문자, 빈 문자열)은 병기 형태 그대로. + func testNonHangulEndingKeepsBothForms() { + XCTAssertEqual(KoreanParticle.subject.attach(to: "안농 [A]"), "안농 [A]이(가)") + XCTAssertEqual(KoreanParticle.object.attach(to: "#123"), "#123을(를)") + XCTAssertEqual(KoreanParticle.direction.attach(to: "Pikachu"), "Pikachu(으)로") + XCTAssertEqual(KoreanParticle.subject.attach(to: ""), "이(가)") + } + + /// 실제 문구 — 스크린샷으로 보고된 "유니란이(가)"·"듀란(으)로"·"란쿨루스(으)로"가 사라진다. + func testKoreanCopyUsesTheMatchingParticle() { + let l = L(.ko) + XCTAssertEqual(l.notifHatchBody("유니란"), "알에서 유니란이 나왔어요!") + XCTAssertTrue(l.notifShinyHatchBody("뚜벅쵸").hasPrefix("이로치 뚜벅쵸가 태어났어요!")) + XCTAssertEqual(l.notifEvolveBody("듀란"), "듀란으로 진화했어요!") + XCTAssertEqual(l.statusEvolved("란쿨루스"), "란쿨루스로 진화했어요!") + XCTAssertEqual(l.eggConfirm("레트라", l.eggName(.rare)), "레트라를 놓아주고 희귀 알로 바꿀까요?") + } + + /// 조사 선택은 한국어 문구에만 적용된다. + func testOtherLanguagesAreUntouched() { + XCTAssertEqual(L(.en).notifHatchBody("Unfezant"), "Unfezant hatched from the egg!") + XCTAssertEqual(L(.ja).notifEvolveBody("デスカーン"), "デスカーン に進化しました!") + } +} diff --git a/scripts/test-gate.sh b/scripts/test-gate.sh index 1e6d1dc69..44609156b 100755 --- a/scripts/test-gate.sh +++ b/scripts/test-gate.sh @@ -22,6 +22,7 @@ LOGIC_CORE=( "Sources/PokeTokenBar/Core/CompanionModel.swift" "Sources/PokeTokenBar/Core/UnownForm.swift" "Sources/PokeTokenBar/Core/CollectionWeight.swift" + "Sources/PokeTokenBar/Core/KoreanParticle.swift" "Sources/PokeTokenBar/Core/CompanionStore.swift" "Sources/PokeTokenBar/Core/PokemonProfile.swift" "Sources/PokeTokenBar/Core/PokemonNameLocalization.swift"