내 작은 왕국 기획 위키

다국어 텍스트

03 만드는 법

이 문서 안에서

사람이 읽는 글자는 거의 전부 i18n/common.<언어>.json 세 파일에 들어 있습니다. 평평한 문자열 사전(1386열쇠 × 3언어)과 108줄짜리 런타임이 전부입니다.

한 줄 요약 — 테스트가 막는 것은 값이 아니라 열쇠가 빠지는 것입니다.


1. 표는 하나, 언어는 셋

단일 출처: src/shared/types/i18n.tsLOCALES = ['ko', 'en', 'ja'].

파일열쇠
i18n/common.ko.json1386한국어(저작 원본)
i18n/common.en.json1386영어
i18n/common.ja.json1386일본어
i18n/_meta.<언어>.json1언어 이름 라벨

중첩 없는 평평한 사전. 복수형·성별 변형 없음. 파일명 규칙 i18n/<네임스페이스>.<언어>.json. 현재 네임스페이스는 common 하나. 열쇠는 정렬 안 됨(저작 순서).

2. 열쇠 이름 규칙

점으로 계층을 나눕니다: <도메인>.<항목> 또는 <도메인>.<하위>.<항목>.

패턴
이름·설명 짝item.bread / item.bread.desc
미션(예외)mission.d_gather_150.title / .desc
주민(예외)villager.name.<id> / villager.desc.<id>
주민 개성villager.trait.<개성> / villager.intro.<id> / villager.quote.<id> / villager.say.<개성>.<상황>

식별자 표기:

종류표기
아이템·전리품snake_caseitem.bone_broth
광산·던전kebab-casemine.pebble-quarry
주민 후보<직업>-<번호>villager.name.blacksmith-6
미션<주기><지표>_<목표치>mission.d_gather_150
상품snake_caseshop.pack.perm_pouch
기금 지표camelCaseshop.fund.metric.stagesCleared

미션 주기 접두어: d_ 일일, w_ 주간, m_ 월간, a_ 업적.

코드가 조립하는 동적 열쇠(전체 검색 불가):

패턴
item.${key} / .desc
inv.category.${category}
intro.name.error.${reason}
villager.name.${id} / .desc
villager.trait.${개성} · villager.intro.${id} · villager.quote.${id} · villager.say.${개성}.${상황}
role.${role}
mission.${id}.title / .desc
shop.pack.${id} / shop.about.${id}
mine.${id} / battle.dungeon.${id}
equip.${role}.${slot}[.${suffix}]
effect.target.${target}

3. 값에 숫자를 꽂는 문법

{이름} 하나뿐. tf(key, params)/\{(\w+)\}/g로 치환합니다. params에 없는 토큰은 그대로 남음. 복수형·순서 재배치 없음. 세 언어의 자리표시자 집합은 127개 열쇠 전부 완전 일치.

주요 토큰: {n}(83) {name}(27) {m}(10) {g}/{s}(각 6) {gold}(5) {lv}(4) {role}/{h}(각 3).

4. 로케일 감지 순서

detectLocale():

  1. <html lang> 속성 — 정확히 일치할 때.
  2. navigator.languages — 전체 일치, 그다음 - 앞 기본 언어 일치.
  3. 둘 다 실패 → en.

game.htmllang= 없음(테스트가 강제). setLocale()<html lang>을 씀 → 전환 후 detectLocale()은 전환값 반환. 세이브에 locale 저장, 신규 세이브 기본값 = detectLocale().

t(key) 폴백: 현재 로케일 → en → 열쇠 문자열 자체. 빈 문자열도 "있음" 취급.

폴백이 다른 표면:

표면폴백
게임 UI (t/tf)로케일 → en → 열쇠 문자열
패치 노트 (pickLocalized)로케일 → en → ko → ja → ""
스토어 캡션 (captionFor)로케일 → ko → ""
스토어 캡션 (messageFor)로케일 → ko → 예외
스토어 등록정보폴백 없음

5. 테스트가 강제하는 계약

tests/i18n-coverage.test.ts:

검사막는 것
세 표의 열쇠 집합 동일한 언어에만 열쇠 추가
빈 값 금지빈 문자열이 폴백을 건너뛰는 것
en·ja 표에 한글 금지번역 누락
인트로가 열쇠를 담음컷씬만 한국어로 남는 것
모든 인트로 beat 3언어 해석값 없는 열쇠
game.htmllang= 없음전 세계를 한 언어에 고정
신규 세이브 로케일 = detectLocale()감지를 기본값이 덮는 것

다른 도메인 테스트 계약:

강제하는 것
레시피 이름·설명 존재, 필수 UI 열쇠 30종, 자리표시자 보존
폐기 직업 role.*·villager.*·equip.* 잔존 금지
후보 66명 개성 보유, 개성·자기소개·대사 3언어 존재, 자기소개·대사 중복 금지
폐기 퀘스트 열쇠 잔존 금지
폐기 접속 알림 4종 잔존 금지
모든 상품에 shop.pack.*/shop.about.* 존재
스토어 이름 == 게임 내 이름(3언어), ≤30자·≤45자
광산·후보 이름·안내 열쇠 존재

6. 금칙어 필터

src/shared/services/name-filter.ts 554줄. 게임군 전체 공통 복사본, 순수 함수.

스코프 둘: name(이름 입력, PROFANITY+NAME_ONLY) · text(채팅, PROFANITY만).

목록
PROFANITY108
NAME_ONLY25
SAFE_WORDS87 (오탐 방지 어간)
RESERVED_ANYWHERE11 (부분 일치)
RESERVED_EXACT13 (전체 일치만)

정규화 파이프라인: 가나 합성 → 한글 외 NFKD+결합문자 제거+NFC → 소문자 → 리트 치환 13개 → 허용 문자만 남기되 원문 위치 기록. 반복 축약 2패스(2회·1회). SAFE_WORDS 어간이 히트를 통째로 덮으면 무시.

공개 API:

함수동작
containsProfanity(raw, scope)첫 히트에서 중단
maskProfanity(raw, maskChar)text 스코프, 히트 구간 마스크
isReservedName(raw)EXACT 전체 일치 + ANYWHERE 부분 일치
sanitizeVisibleText(raw)보이지 않는 문자 제거 → 공백 축약 → 양끝 다듬기
checkDisplayName(raw, {min,max})다듬기→길이→금칙어(name)→예약어
safeDisplayName(raw, seed, limits)통과 시 그대로, 아니면 Player####

거절 사유(intro.name.error.${reason}): empty · too-short · too-long · profanity · reserved.

길이 상한:

상수
인트로 이름 최대8
checkDisplayName 기본 max16
서버 이름/채팅/아바타24 / 280 / 32
피드백 min/max3 / 1000

7. 용어 원본·흔들림

용어의 3개 국어 정본은 용어집에 있습니다.

8. 톤

표면화자한국어 어투
인트로 나레이션주인공 독백해라체·과거형성문은 부서져 있었다.
튜토리얼집사 바르톨하십시오체(폐하)준비는 끝났습니다. 폐하.
일반 UI시스템합쇼체 -습니다아직 주민이 없습니다.
확인 질문시스템-할까요?{name}(으)로 이동할까요?
버튼·라벨명사/명령 어간수거·제작·영입
아이템 설명시스템-입니다진한 육수입니다.
주민 소개시스템명사구/-합니다손이 야무진 초보 농부
주민 자기소개·대사주민 본인1인칭 합쇼체(폐하께)미나입니다. 한 줄이라도 제대로 매겠습니다.
상품 설명개발자 해설합쇼체교환비는 낮게 잡았습니다.

9. 문구 추가·수정·삭제

추가: 세 파일 같은 자리에 열쇠 추가 → 같은 토큰 집합 → t()/tf() 호출 → pnpm test. 수정: 세 파일 함께. 상품 이름은 fastlane/lib/seal_iap.rb도 함께. 삭제: 세 파일 함께 + 잔존 방지 테스트 추가가 관행.


이어서 읽기

이 문서를 가리키는 곳