Files
cultivation-world-simulator/docs/specs/i18n-dynamic-text.md
4thfever e1091fdf5a 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>
2026-01-24 13:47:23 +08:00

478 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)
```