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
39 changes: 39 additions & 0 deletions Sources/PokeTokenBar/Core/KoreanParticle.swift
Original file line number Diff line number Diff line change
@@ -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<UInt32> = 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
}
}
10 changes: 5 additions & 5 deletions Sources/PokeTokenBar/Core/Localization.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1067,15 +1067,15 @@ 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!") }
/// 실제 판정에 쓴 분모를 받는다 — 부적이 있으면 64 가 아니다.
func notifShinyHatchBody(_ name: String, odds: UInt64) -> String { t("이로치 \(name)이(가) 태어났어요! (1/\(odds))", "A shiny \(name) hatched! (1 in \(odds))", "色違いの \(name) が生まれました!(1/\(odds))", "¡Nació un \(name) variocolor! (1 entre \(odds))", "Un \(name) chromatique est né ! (1 sur \(odds))", "Nasceu um \(name) shiny! (1 em \(odds))", "Ein schillerndes \(name) ist geschlüpft! (1/\(odds))") }
func notifShinyHatchBody(_ name: String, odds: UInt64) -> String { t("이로치 \(KoreanParticle.subject.attach(to: name)) 태어났어요! (1/\(odds))", "A shiny \(name) hatched! (1 in \(odds))", "色違いの \(name) が生まれました!(1/\(odds))", "¡Nació un \(name) variocolor! (1 entre \(odds))", "Un \(name) chromatique est né ! (1 sur \(odds))", "Nasceu um \(name) shiny! (1 em \(odds))", "Ein schillerndes \(name) ist geschlüpft! (1/\(odds))") }
var eggImminent: String { t("곧 부화해요!", "About to hatch!", "もうすぐ孵化!", "¡Está a punto de eclosionar!", "Sur le point d'éclore !", "Está quase chocando!", "Schlüpft gleich!") }
/// 첫 실행(아직 토큰 적립 0) 안내 — "왜 아무 일도 안 일어나지"를 방지.
/// `amount` 는 성장 난이도가 반영된 부화 임계(포맷된 값) — 고정 5M 이 아니다.
Expand All @@ -1088,7 +1088,7 @@ struct L {
"Cresce com o uso das suas ferramentas locais de programação com IA. O ovo choca depois de uns \(amount) de tokens.",
"Wächst mit der Nutzung deiner lokalen KI-Coding-Tools. Nach etwa \(amount) 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!") }
Expand Down Expand Up @@ -1421,7 +1421,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)?",
Expand Down
48 changes: 48 additions & 0 deletions Tests/PokeTokenBarTests/KoreanParticleTests.swift
Original file line number Diff line number Diff line change
@@ -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("유니란"), "알에서 유니란이 나왔어요!")
XCTAssertEqual(l.notifShinyHatchBody("뚜벅쵸", odds: 48), "이로치 뚜벅쵸가 태어났어요! (1/48)")
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("デスカーン"), "デスカーン に進化しました!")
}
}
1 change: 1 addition & 0 deletions scripts/test-gate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading