Feat/i18n (#92)
* feat: add vue-i18n * feat: add vue-i18n * feat: add vue-i18n * feat: add language class * add: en templates and configs * add: en names * refactor: name gender id and sect id * feat(i18n): add gettext infrastructure for dynamic text translation (#81) * feat(i18n): add gettext infrastructure for dynamic text translation - Add src/i18n/ module with t() translation function - Add .po/.mo files for zh_CN and en_US locales - Update LanguageManager to reload translations on language change - Add comprehensive tests (14 tests, all passing) - Add implementation spec at docs/specs/i18n-dynamic-text.md Phase 1 of i18n dynamic text implementation. * feat(i18n): expand .po files with comprehensive translation entries Add translation messages for: - Battle result messages (fatal/non-fatal outcomes) - Fortune event messages (item discovery, cultivation gains) - Misfortune event messages (losses, damage, regression) - Death reason messages - Item exchange messages (equip, sell, discard) - Single choice context and option labels - Common labels (weapon, auxiliary, technique, elixir) Both zh_CN and en_US locales updated with matching entries. * test: add .po file integrity tests * feat: i18n for actions * feat: i18n for effects * feat: i18n for gathering * feat: i18n for classes * feat: i18n for classes * feat: i18n for classes * feat: i18n for classes * fix bugs * fix bugs * fix bugs * fix bugs * fix bugs * fix bugs * fix bugs * fix bugs * update csv * update world info * update prompt * update prompt * fix bug * fix bug * fix bug * fix bug * fix bug * fix bug * fix bug * fix bug * fix bug * update * update * update * update * update * update * update --------- Co-authored-by: Zihao Xu <xzhseh@gmail.com>
This commit is contained in:
477
docs/specs/i18n-dynamic-text.md
Normal file
477
docs/specs/i18n-dynamic-text.md
Normal file
@@ -0,0 +1,477 @@
|
||||
# i18n Dynamic Text Implementation Spec
|
||||
|
||||
## Overview
|
||||
|
||||
This spec covers the internationalization of **dynamic text** (runtime-generated strings) using gettext.
|
||||
|
||||
Static content (CSV configs, LLM templates, UI labels) is already handled. This spec addresses the remaining dynamic f-strings in Python code.
|
||||
|
||||
---
|
||||
|
||||
## Current State
|
||||
|
||||
Dynamic Chinese strings are hardcoded in:
|
||||
|
||||
```python
|
||||
# battle.py
|
||||
text = f"{winner.name} 战胜了 {loser.name},造成 {damage} 点伤害"
|
||||
|
||||
# fortune.py
|
||||
event_text = f"遭遇奇遇({theme}),{res_text}"
|
||||
|
||||
# misfortune.py
|
||||
res_text = f"{avatar.name} 损失灵石 {loss} 枚"
|
||||
```
|
||||
|
||||
These need to be internationalized.
|
||||
|
||||
---
|
||||
|
||||
## Solution: gettext
|
||||
|
||||
Use Python's standard `gettext` module with `.po/.mo` translation files.
|
||||
|
||||
### Why gettext?
|
||||
|
||||
- Python standard library (no extra dependencies).
|
||||
- Industry standard for i18n.
|
||||
- Good tooling support (poedit, xgettext, msgfmt).
|
||||
- Supports pluralization and context.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Infrastructure
|
||||
|
||||
#### 1.1 Directory Structure
|
||||
|
||||
```
|
||||
src/i18n/
|
||||
├── __init__.py # Export t() function
|
||||
└── locales/
|
||||
├── zh_CN/
|
||||
│ └── LC_MESSAGES/
|
||||
│ ├── messages.po # Chinese translations (source)
|
||||
│ └── messages.mo # Compiled binary (runtime)
|
||||
└── en_US/
|
||||
└── LC_MESSAGES/
|
||||
├── messages.po # English translations (source)
|
||||
└── messages.mo # Compiled binary (runtime)
|
||||
```
|
||||
|
||||
#### 1.2 Create `src/i18n/__init__.py`
|
||||
|
||||
```python
|
||||
import gettext
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from src.classes.language import language_manager, LanguageType
|
||||
|
||||
# Cache for loaded translations
|
||||
_translations: dict[str, gettext.GNUTranslations] = {}
|
||||
|
||||
def _get_translation() -> Optional[gettext.GNUTranslations]:
|
||||
"""Get translation object for current language."""
|
||||
lang = str(language_manager)
|
||||
|
||||
if lang not in _translations:
|
||||
locale_dir = Path(__file__).parent / "locales"
|
||||
|
||||
# Map language codes to gettext locale names
|
||||
locale_map = {
|
||||
"zh-CN": "zh_CN",
|
||||
"en-US": "en_US",
|
||||
}
|
||||
locale_name = locale_map.get(lang, "zh_CN")
|
||||
|
||||
try:
|
||||
trans = gettext.translation(
|
||||
"messages",
|
||||
localedir=str(locale_dir),
|
||||
languages=[locale_name]
|
||||
)
|
||||
_translations[lang] = trans
|
||||
except FileNotFoundError:
|
||||
_translations[lang] = None
|
||||
|
||||
return _translations.get(lang)
|
||||
|
||||
def t(message: str, **kwargs) -> str:
|
||||
"""
|
||||
Translate a message and format with kwargs.
|
||||
|
||||
Usage:
|
||||
t("{winner} defeated {loser}", winner="Zhang San", loser="Li Si")
|
||||
|
||||
The message key is in English. Translations map English -> target language.
|
||||
"""
|
||||
trans = _get_translation()
|
||||
|
||||
if trans:
|
||||
translated = trans.gettext(message)
|
||||
else:
|
||||
translated = message
|
||||
|
||||
if kwargs:
|
||||
return translated.format(**kwargs)
|
||||
return translated
|
||||
|
||||
def reload_translations():
|
||||
"""Clear translation cache (call after language change)."""
|
||||
_translations.clear()
|
||||
```
|
||||
|
||||
#### 1.3 Update `LanguageManager`
|
||||
|
||||
```python
|
||||
# In src/classes/language.py, add callback for language change
|
||||
|
||||
def set_language(self, lang_code: str):
|
||||
try:
|
||||
self._current = LanguageType(lang_code)
|
||||
except ValueError:
|
||||
self._current = LanguageType.ZH_CN
|
||||
|
||||
# Reload translations when language changes
|
||||
from src.i18n import reload_translations
|
||||
reload_translations()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Collect Dynamic Strings
|
||||
|
||||
#### 2.1 Files to Scan
|
||||
|
||||
| File | Content Type |
|
||||
|------|--------------|
|
||||
| `src/classes/battle.py` | Battle result messages |
|
||||
| `src/classes/fortune.py` | Fortune event messages |
|
||||
| `src/classes/misfortune.py` | Misfortune event messages |
|
||||
| `src/classes/tribulation.py` | Tribulation messages |
|
||||
| `src/classes/death_reason.py` | Death reason text |
|
||||
| `src/classes/action/*.py` | Action result messages |
|
||||
| `src/classes/mutual_action/*.py` | Mutual action messages |
|
||||
| `src/classes/single_choice.py` | Choice result messages |
|
||||
|
||||
#### 2.2 Extraction Command
|
||||
|
||||
```bash
|
||||
# Find all f-strings with Chinese characters
|
||||
grep -rn "f\".*[\u4e00-\u9fff].*\"" src/classes/ --include="*.py"
|
||||
grep -rn "f'.*[\u4e00-\u9fff].*'" src/classes/ --include="*.py"
|
||||
```
|
||||
|
||||
#### 2.3 Expected Strings (Examples)
|
||||
|
||||
From `battle.py`:
|
||||
- `"{winner} 战胜了 {loser},造成 {damage} 点伤害。{loser} 遭受重创,当场陨落。"`
|
||||
- `"{winner} 战胜了 {loser},{loser} 受伤 {loser_dmg} 点,{winner} 也受伤 {winner_dmg} 点。"`
|
||||
|
||||
From `fortune.py`:
|
||||
- `"遭遇奇遇({theme}),{result}"`
|
||||
- `"发现了兵器『{weapon}』,{exchange_result}"`
|
||||
- `"{name} 获得灵石 {amount} 枚"`
|
||||
- `"{name} 修为增长 {exp} 点"`
|
||||
- `"{apprentice} 拜 {master} 为师"`
|
||||
|
||||
From `misfortune.py`:
|
||||
- `"遭遇霉运({theme}),{result}"`
|
||||
- `"{name} 损失灵石 {amount} 枚"`
|
||||
- `"{name} 受到伤害 {damage} 点,剩余HP {current}/{max}"`
|
||||
- `"{name} 修为倒退 {amount} 点"`
|
||||
|
||||
From `death_reason.py`:
|
||||
- `"被{killer}杀害"`
|
||||
- `"重伤不治身亡"`
|
||||
- `"寿元耗尽而亡"`
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Create .po Files
|
||||
|
||||
#### 3.1 Message Key Convention
|
||||
|
||||
Use **English as the message key** (msgid). This makes the code readable and serves as fallback.
|
||||
|
||||
```po
|
||||
msgid "{winner} defeated {loser}, dealing {damage} damage. {loser} was fatally wounded and perished."
|
||||
msgstr ""
|
||||
```
|
||||
|
||||
#### 3.2 Chinese Translation File
|
||||
|
||||
```po
|
||||
# src/i18n/locales/zh_CN/LC_MESSAGES/messages.po
|
||||
|
||||
# Header
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Language: zh_CN\n"
|
||||
|
||||
# Battle messages
|
||||
msgid "{winner} defeated {loser}, dealing {damage} damage. {loser} was fatally wounded and perished."
|
||||
msgstr "{winner} 战胜了 {loser},造成 {damage} 点伤害。{loser} 遭受重创,当场陨落。"
|
||||
|
||||
msgid "{winner} defeated {loser}. {loser} took {loser_dmg} damage, {winner} also took {winner_dmg} damage."
|
||||
msgstr "{winner} 战胜了 {loser},{loser} 受伤 {loser_dmg} 点,{winner} 也受伤 {winner_dmg} 点。"
|
||||
|
||||
# Fortune messages
|
||||
msgid "Encountered fortune ({theme}), {result}"
|
||||
msgstr "遭遇奇遇({theme}),{result}"
|
||||
|
||||
msgid "Found weapon '{weapon}', {exchange_result}"
|
||||
msgstr "发现了兵器『{weapon}』,{exchange_result}"
|
||||
|
||||
msgid "{name} obtained {amount} spirit stones"
|
||||
msgstr "{name} 获得灵石 {amount} 枚"
|
||||
|
||||
msgid "{name} cultivation increased by {exp} points"
|
||||
msgstr "{name} 修为增长 {exp} 点"
|
||||
|
||||
msgid "{apprentice} became disciple of {master}"
|
||||
msgstr "{apprentice} 拜 {master} 为师"
|
||||
|
||||
# Misfortune messages
|
||||
msgid "Encountered misfortune ({theme}), {result}"
|
||||
msgstr "遭遇霉运({theme}),{result}"
|
||||
|
||||
msgid "{name} lost {amount} spirit stones"
|
||||
msgstr "{name} 损失灵石 {amount} 枚"
|
||||
|
||||
msgid "{name} took {damage} damage, HP remaining {current}/{max}"
|
||||
msgstr "{name} 受到伤害 {damage} 点,剩余HP {current}/{max}"
|
||||
|
||||
msgid "{name} cultivation regressed by {amount} points"
|
||||
msgstr "{name} 修为倒退 {amount} 点"
|
||||
|
||||
# Death reasons
|
||||
msgid "Killed by {killer}"
|
||||
msgstr "被{killer}杀害"
|
||||
|
||||
msgid "Died from severe injuries"
|
||||
msgstr "重伤不治身亡"
|
||||
|
||||
msgid "Died of old age"
|
||||
msgstr "寿元耗尽而亡"
|
||||
```
|
||||
|
||||
#### 3.3 English Translation File
|
||||
|
||||
```po
|
||||
# src/i18n/locales/en_US/LC_MESSAGES/messages.po
|
||||
|
||||
# Header
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Language: en_US\n"
|
||||
|
||||
# Battle messages
|
||||
msgid "{winner} defeated {loser}, dealing {damage} damage. {loser} was fatally wounded and perished."
|
||||
msgstr "{winner} defeated {loser}, dealing {damage} damage. {loser} was fatally wounded and perished."
|
||||
|
||||
# ... (msgid == msgstr for English)
|
||||
```
|
||||
|
||||
#### 3.4 Compile .po to .mo
|
||||
|
||||
```bash
|
||||
# Install gettext tools if needed (macOS)
|
||||
brew install gettext
|
||||
|
||||
# Compile
|
||||
msgfmt src/i18n/locales/zh_CN/LC_MESSAGES/messages.po -o src/i18n/locales/zh_CN/LC_MESSAGES/messages.mo
|
||||
msgfmt src/i18n/locales/en_US/LC_MESSAGES/messages.po -o src/i18n/locales/en_US/LC_MESSAGES/messages.mo
|
||||
```
|
||||
|
||||
Add to Makefile or script:
|
||||
```makefile
|
||||
compile-i18n:
|
||||
msgfmt src/i18n/locales/zh_CN/LC_MESSAGES/messages.po -o src/i18n/locales/zh_CN/LC_MESSAGES/messages.mo
|
||||
msgfmt src/i18n/locales/en_US/LC_MESSAGES/messages.po -o src/i18n/locales/en_US/LC_MESSAGES/messages.mo
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: Modify Code
|
||||
|
||||
#### 4.1 Example: battle.py
|
||||
|
||||
```python
|
||||
# Before
|
||||
text = f"{winner.name} 战胜了 {loser.name},造成 {l_dmg} 点伤害。{loser.name} 遭受重创,当场陨落。"
|
||||
|
||||
# After
|
||||
from src.i18n import t
|
||||
|
||||
text = t(
|
||||
"{winner} defeated {loser}, dealing {damage} damage. {loser} was fatally wounded and perished.",
|
||||
winner=winner.name,
|
||||
loser=loser.name,
|
||||
damage=l_dmg
|
||||
)
|
||||
```
|
||||
|
||||
#### 4.2 Example: fortune.py
|
||||
|
||||
```python
|
||||
# Before
|
||||
event_text = f"遭遇奇遇({theme}),{res_text}"
|
||||
|
||||
# After
|
||||
from src.i18n import t
|
||||
|
||||
event_text = t("Encountered fortune ({theme}), {result}", theme=theme, result=res_text)
|
||||
```
|
||||
|
||||
#### 4.3 Files to Modify
|
||||
|
||||
- [ ] `src/classes/battle.py`
|
||||
- [ ] `src/classes/fortune.py`
|
||||
- [ ] `src/classes/misfortune.py`
|
||||
- [ ] `src/classes/tribulation.py`
|
||||
- [ ] `src/classes/death_reason.py`
|
||||
- [ ] `src/classes/single_choice.py`
|
||||
- [ ] Other files as discovered
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: Testing
|
||||
|
||||
#### 5.1 Unit Tests
|
||||
|
||||
```python
|
||||
# tests/test_i18n.py
|
||||
|
||||
import pytest
|
||||
from src.i18n import t, reload_translations
|
||||
from src.classes.language import language_manager
|
||||
|
||||
class TestI18n:
|
||||
def setup_method(self):
|
||||
reload_translations()
|
||||
|
||||
def test_chinese_translation(self):
|
||||
language_manager.set_language("zh-CN")
|
||||
result = t("{name} lost {amount} spirit stones", name="张三", amount=100)
|
||||
assert "张三" in result
|
||||
assert "损失灵石" in result
|
||||
assert "100" in result
|
||||
|
||||
def test_english_translation(self):
|
||||
language_manager.set_language("en-US")
|
||||
result = t("{name} lost {amount} spirit stones", name="Zhang San", amount=100)
|
||||
assert "Zhang San" in result
|
||||
assert "lost" in result
|
||||
assert "spirit stones" in result
|
||||
|
||||
def test_fallback_on_missing(self):
|
||||
language_manager.set_language("en-US")
|
||||
# Unknown key returns the key itself
|
||||
result = t("Unknown message {x}", x="test")
|
||||
assert result == "Unknown message test"
|
||||
```
|
||||
|
||||
#### 5.2 Integration Test
|
||||
|
||||
Run a game session, switch languages, verify event log displays correctly.
|
||||
|
||||
---
|
||||
|
||||
### Phase 6: Save/Load Language
|
||||
|
||||
#### 6.1 Save Structure
|
||||
|
||||
```python
|
||||
# Add language to save data
|
||||
save_data = {
|
||||
"version": "1.0",
|
||||
"language": str(language_manager), # "zh-CN" or "en-US"
|
||||
"timestamp": ...,
|
||||
"world": ...,
|
||||
"avatars": ...,
|
||||
}
|
||||
```
|
||||
|
||||
#### 6.2 Load Logic
|
||||
|
||||
```python
|
||||
def load_save(path: str):
|
||||
data = load_json(path)
|
||||
|
||||
# Restore language setting
|
||||
if "language" in data:
|
||||
from src.classes.language import language_manager
|
||||
from src.utils.config import update_paths_for_language
|
||||
|
||||
language_manager.set_language(data["language"])
|
||||
update_paths_for_language()
|
||||
|
||||
# Load rest of save data...
|
||||
```
|
||||
|
||||
#### 6.3 Files to Modify
|
||||
|
||||
- [ ] Save function (add language field)
|
||||
- [ ] Load function (restore language)
|
||||
|
||||
---
|
||||
|
||||
## File Summary
|
||||
|
||||
### New Files
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `src/i18n/__init__.py` | Translation module with `t()` function |
|
||||
| `src/i18n/locales/zh_CN/LC_MESSAGES/messages.po` | Chinese translations |
|
||||
| `src/i18n/locales/zh_CN/LC_MESSAGES/messages.mo` | Compiled Chinese |
|
||||
| `src/i18n/locales/en_US/LC_MESSAGES/messages.po` | English translations |
|
||||
| `src/i18n/locales/en_US/LC_MESSAGES/messages.mo` | Compiled English |
|
||||
| `tests/test_i18n.py` | Unit tests |
|
||||
|
||||
### Modified Files
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `src/classes/language.py` | Add `reload_translations()` callback |
|
||||
| `src/classes/battle.py` | Replace f-strings with `t()` |
|
||||
| `src/classes/fortune.py` | Replace f-strings with `t()` |
|
||||
| `src/classes/misfortune.py` | Replace f-strings with `t()` |
|
||||
| `src/classes/death_reason.py` | Replace f-strings with `t()` |
|
||||
| Save/Load code | Add language persistence |
|
||||
|
||||
---
|
||||
|
||||
## Estimated Effort
|
||||
|
||||
| Phase | Time |
|
||||
|-------|------|
|
||||
| Phase 1: Infrastructure | 1 hour |
|
||||
| Phase 2: Collect strings | 1-2 hours |
|
||||
| Phase 3: Create .po files | 1-2 hours |
|
||||
| Phase 4: Modify code | 2-3 hours |
|
||||
| Phase 5: Testing | 1 hour |
|
||||
| Phase 6: Save/Load | 30 min |
|
||||
| **Total** | **~8 hours** |
|
||||
|
||||
---
|
||||
|
||||
## Decisions
|
||||
|
||||
1. **Commit `.mo` files to git** - Simpler workflow, no CI compilation needed. `.mo` files are cross-platform compatible (macOS/Windows/Linux).
|
||||
|
||||
2. **Pluralization** - Not needed for now. Chinese doesn't have plural forms, and English strings can be written to avoid pluralization (e.g., "lost 100 spirit stones" works for any number).
|
||||
|
||||
3. **Fortune/misfortune themes go through `t()`** - Yes. The theme values come from CSV (already translated), but the wrapper strings like `"遭遇奇遇({theme})"` need to be translated via `t()`:
|
||||
```python
|
||||
# Theme from CSV (already translated based on language)
|
||||
theme = "Stumbled into Cave Dwelling" # or "误入洞府"
|
||||
|
||||
# Wrapper string needs t()
|
||||
event_text = t("Encountered fortune ({theme}), {result}", theme=theme, result=res_text)
|
||||
```
|
||||
Reference in New Issue
Block a user