Rework wolf workflow for stability
This commit is contained in:
parent
e0989112d7
commit
de2ad8d511
8 changed files with 469 additions and 52 deletions
10
README.md
10
README.md
|
|
@ -324,11 +324,11 @@ Open the **Workflow** tab and choose **Wolf RPG (WolfDawn)** from the engine sel
|
|||
|
||||
| Step | Action |
|
||||
|------|--------|
|
||||
| **0 Project** | Select the game folder, **Unpack** the `.wolf` archives into a loose `Data/` folder, then **Extract text** (maps, common events, databases, `Game.dat`, external event text, and the project-wide name glossary). Everything is staged into `files/` for translation; `names.json` is translated later (Step 3) and the Step 2 bulk run skips it. Extraction also snapshots the pristine (untranslated) binaries into `wolf_json/originals/` so Step 4 can inject idempotently. **Set up git tracking** by copying the `gameupdate/` folder (updater scripts, `patch.sh`/`patch.ps1`, and a `.gitignore` that excludes the original game files) into the game root, so the translation project can be tracked with git and later shipped as a git-based patch players apply themselves. Finally, **Format extracted JSON** (dazedformat) normalises `wolf_json/` and `files/` to the same layout the translator writes back (`json.dump`, indent 4) — run it once and commit it as your baseline so later injects produce clean, line-level git diffs instead of reformatting whole files. |
|
||||
| **1 Glossary** | Build `vocab.txt` before translating: copy the WOLF-tailored prompt into Cursor/Copilot with the extracted `files/` JSON, let it discover character names, speech registers, and lore terms, then paste the result into the in-tab editor and save (the shared `vocab.txt` is used by every translation batch to keep names and voice consistent). Item/skill/enemy value names (`names.json`) are handled later in Step 3, not here. |
|
||||
| **2 Translate** | Pick the **Translation mode** (Normal or Batch - Batch uses the Anthropic Batches API, ~50% cheaper, Claude only), then run the `Wolf RPG (WolfDawn)` module over `files/`. Under **Speaker handling**, speaker attribution is automatic - WolfDawn already tags who speaks on each line, so lines with a real nameplate (a face window precedes the name) are always reshaped into the `[Speaker]: line` convention the prompt understands (translating the speaker tag), then restored to WOLF's native `Speaker⏎line` layout on inject. The only judgement call is WolfDawn's low-confidence guesses (a short first line with no face window that may or may not be a name); copy the **speaker-format prompt** to have the repo-aware AI inspect `files/` and recommend ENABLE/DISABLE, then set the single low-confidence checkbox to match (there is no toggle for the reliable nameplates - they need no configuration). Under **Text wrap width**, set whether/where translated dialogue re-wraps to fit WOLF's message box (same `dazedwrap` engine the RPG Maker workflow uses, saved to `.env` as `wolfWrap` / `wolfWidth`); only the dialogue body is wrapped - a speaker's nameplate line is always kept separate so the `⏎` after a name is never folded into the text. Only the `text` fields are filled in; `source` is preserved so injection can verify each line. |
|
||||
| **3 Names** | Curate `names.json` (item/skill/enemy/variable value names). **Translating all of them breaks the game** - many WOLF value names double as logic keys (compared by value, used as variable/file/event names), so by default none are translated. WolfDawn tags each name with its WOLF database **category** (the `note` field), so instead of judging thousands of names you pick which *categories* are safe. Copy the **names-safety prompt** into a repo-aware AI (Cursor/Copilot with `files/` open); it groups names by category, checks how each is used, and returns a JSON list of the safe categories in a code block. Paste that list and click **Apply** to tick the matching rows (or tick them by hand - each row shows the category, its name count, and an example), then **Save** to `data/wolf_safe_notes.json`. Finally **Translate safe names** - the WolfDawn module only translates entries whose category you approved and leaves every other name identical to the source; `names.json`'s structure is unchanged. Conservative by design: nothing changes until you opt categories in. |
|
||||
| **4 Inject** | Write the translations back into the game's `Data/` binaries **and** refresh the git-tracked `wolf_json/` with the translated JSON, so the game project's git diff shows both the JSON source and the injected binaries. Injection always reads from a **pristine snapshot** of the original binaries (`wolf_json/originals/`, captured during Step 0 extraction and rebuilt from the `.wolf`/`.wolf.bak` archives if missing) and writes the result into the live `Data/` file. This keeps injection **idempotent** - WolfDawn locates each Japanese source string in the original, so re-injecting after fixing a few lines actually updates the binary instead of silently no-op'ing (the old in-place inject broke once a file had already been translated). Toggle `--en-punct` (convert Japanese punctuation to ASCII) and `--allow-code-drift` (relax the inline-code guard) as needed, and use **Check name consistency** to catch names translated differently across files. **Quick inject** lists the files you've translated so far (present in `translated/`) so you can tick just a few, inject them straight into `Data/`, and review the git diff / test in-game - ideal for iterating. **Inject all translations** writes every document, including the safe name values from Step 3, across `Data/`. |
|
||||
| **0 Project** | Select the game folder, **Unpack** the `.wolf` archives into a loose `Data/` folder, then **Extract text** (maps, common events, databases, `Game.dat`, external event text, and the project-wide name glossary). Everything is staged into `files/` for translation, including `names.json` (translated later in Phase 0 only). Extraction also snapshots the pristine (untranslated) binaries into `wolf_json/originals/` so Step 4 can inject idempotently. **Set up git tracking** by copying the `gameupdate/` folder (updater scripts, `patch.sh`/`patch.ps1`, and a `.gitignore` that excludes the original game files) into the game root, so the translation project can be tracked with git and later shipped as a git-based patch players apply themselves. Finally, **Format extracted JSON** (dazedformat) normalises `wolf_json/` and `files/` to the same layout the translator writes back (`json.dump`, indent 4) — run it once and commit it as your baseline so later injects produce clean, line-level git diffs instead of reformatting whole files. |
|
||||
| **1 Glossary** | Build `vocab.txt` before translating: copy the WOLF-tailored prompt into Cursor/Copilot with the extracted `files/` JSON, let it discover character names, speech registers, and lore terms, then paste the result into the in-tab editor and save (the shared `vocab.txt` is used by every translation batch to keep names and voice consistent). Item/skill/enemy value names (`names.json`) are curated in Step 2 and harvested into `vocab.txt` automatically during Phase 0 — do not list them here. |
|
||||
| **2 Names** | Curate `names.json` (item/skill/enemy/variable value names). **Translating all of them breaks the game** - many WOLF value names double as logic keys (compared by value, used as variable/file/event names), so by default none are translated. WolfDawn tags each name with its WOLF database **category** (the `note` field), so instead of judging thousands of names you pick which *categories* are safe. Copy the **names-safety prompt** into a repo-aware AI (Cursor/Copilot with `files/` open); it groups names by category, checks how each is used, and returns a JSON list of the safe categories in a code block. Paste that list and click **Apply** to tick the matching rows (or tick them by hand - each row shows the category, its name count, and an example), then **Save** to `data/wolf_safe_notes.json`. You can also run **Translate safe names (Phase 0)** from here once categories are saved. |
|
||||
| **3 Translate** | Pick the **Translation mode** (Normal or Batch - Batch uses the Anthropic Batches API, ~50% cheaper, Claude only), then run the `Wolf RPG (WolfDawn)` module in three ordered phases (RPG Maker's DB-first strategy): **Phase 0 · Names → vocab.txt** translates only the safe name categories from Step 2 and harvests them into `vocab.txt` (grouped by bilingual category headers such as `Weapon · 武器`); **Phase 1 · Database text** translates item/skill/state descriptions and system messages from `DataBase.project.json` / `CDataBase.project.json`; **Phase 2 · Dialogue & events** translates maps (`.mps`), `CommonEvent.dat.json`, `Game.dat`, and `Evtext`. Run them in order so later phases benefit from the glossary seeded in Phase 0. Under **Speaker handling**, speaker attribution is automatic - WolfDawn already tags who speaks on each line, so lines with a real nameplate (a face window precedes the name) are always reshaped into the `[Speaker]: line` convention the prompt understands (translating the speaker tag), then restored to WOLF's native `Speaker⏎line` layout on inject. The only judgement call is WolfDawn's low-confidence guesses (a short first line with no face window that may or may not be a name); copy the **speaker-format prompt** to have the repo-aware AI inspect `files/` and recommend ENABLE/DISABLE, then set the single low-confidence checkbox to match (there is no toggle for the reliable nameplates - they need no configuration). Under **Text wrap width**, set whether/where translated dialogue re-wraps to fit WOLF's message box (same `dazedwrap` engine the RPG Maker workflow uses, saved to `.env` as `wolfWrap` / `wolfWidth`); only the dialogue body is wrapped - a speaker's nameplate line is always kept separate so the `⏎` after a name is never folded into the text. Only the `text` fields are filled in; `source` is preserved so injection can verify each line. |
|
||||
| **4 Inject** | Write the translations back into the game's `Data/` binaries **and** refresh the git-tracked `wolf_json/` with the translated JSON, so the game project's git diff shows both the JSON source and the injected binaries. Injection always reads from a **pristine snapshot** of the original binaries (`wolf_json/originals/`, captured during Step 0 extraction and rebuilt from the `.wolf`/`.wolf.bak` archives if missing) and writes the result into the live `Data/` file. This keeps injection **idempotent** - WolfDawn locates each Japanese source string in the original, so re-injecting after fixing a few lines actually updates the binary instead of silently no-op'ing (the old in-place inject broke once a file had already been translated). Toggle `--en-punct` (convert Japanese punctuation to ASCII) and `--allow-code-drift` (relax the inline-code guard) as needed, and use **Check name consistency** to catch names translated differently across files. **Quick inject** lists the files you've translated so far (present in `translated/`) so you can tick just a few, inject them straight into `Data/`, and review the git diff / test in-game - ideal for iterating. **Inject all translations** writes every document, including the safe name values from Phase 0, across `Data/`. |
|
||||
| **5 Package** | Either run from the loose `Data/` folder (backs up `Data.wolf` → `Data.wolf.bak`), or **Repack** a fresh `Data.wolf`, inheriting the original archive's encryption. |
|
||||
| **6 Saves** | *(Optional)* Update existing `.sav` files so old Japanese saves load cleanly in the translated build. Originals are backed up automatically. |
|
||||
|
||||
|
|
|
|||
|
|
@ -11,11 +11,19 @@ Mirrors the RPGMaker WorkflowTab, driven by the vendored WolfDawn ``wolf`` CLI
|
|||
produce clean git diffs
|
||||
Step 1 Glossary - build vocab.txt (characters / worldbuilding terms) before
|
||||
translating so the AI keeps names and voice consistent
|
||||
Step 2 Translate - run the "Wolf RPG (WolfDawn)" module over files/
|
||||
Step 3 Names - curate names.json (item/skill/enemy value names). Blindly
|
||||
Step 2 Names - curate names.json (item/skill/enemy value names). Blindly
|
||||
translating all of them breaks games that reference names by
|
||||
value, so a repo-aware AI classifies which are safe to
|
||||
translate and leaves the rest as source (done late, on purpose)
|
||||
value, so a repo-aware AI classifies which categories are safe
|
||||
to translate and leaves the rest as source. Done before
|
||||
translating so Phase 0 can seed the glossary from them.
|
||||
Step 3 Translate - run the "Wolf RPG (WolfDawn)" module over files/ in three
|
||||
ordered phases (RPGMaker's DB-first strategy):
|
||||
Phase 0 Names - the safe name categories from Step 2;
|
||||
their translations are harvested into
|
||||
vocab.txt so later phases stay consistent
|
||||
Phase 1 Database - item/skill/state descriptions and system
|
||||
messages (DataBase/CDataBase.project)
|
||||
Phase 2 Dialogue - maps, CommonEvent, Game.dat, Evtext
|
||||
Step 4 Inject - inject translations + curated names back into the Data/ binaries
|
||||
and refresh the git-tracked wolf_json/ with the translated
|
||||
JSON; quick-inject picks just a few translated files for
|
||||
|
|
@ -23,10 +31,11 @@ Mirrors the RPGMaker WorkflowTab, driven by the vendored WolfDawn ``wolf`` CLI
|
|||
Step 5 Package - run from a loose Data/ folder, or repack Data.wolf
|
||||
Step 6 Saves - fix baked strings in existing .sav files (optional)
|
||||
|
||||
names.json is deliberately NOT staged into files/ and NOT auto-translated - many
|
||||
names.json is staged into files/ but is NOT translated in the bulk phases - many
|
||||
WOLF "value names" double as logic keys (referenced by value in event code), so
|
||||
translating them all corrupts the game. It stays in wolf_json/ until Step 3, where
|
||||
an AI with repo context marks the safe subset; only those are injected in Step 4.
|
||||
translating them all corrupts the game. Step 2 marks the safe subset (by category),
|
||||
Phase 0 translates only those and harvests them into vocab.txt, and Step 4 injects
|
||||
the result.
|
||||
|
||||
The extracted JSON is staged in ``<game_root>/wolf_json/`` (a manifest maps each
|
||||
file back to its base binary), then imported into ``files/`` for the shared
|
||||
|
|
@ -84,6 +93,13 @@ MANIFEST_NAME = "manifest.json"
|
|||
NAMES_JSON = "names.json"
|
||||
WORK_DIR_NAME = "wolf_json"
|
||||
|
||||
# Translation phases, keyed by the manifest ``kind`` of each extracted file.
|
||||
# Mirrors RPGMaker's DB-first strategy: translate names first (they seed the
|
||||
# glossary), then database descriptions, then the bulk dialogue.
|
||||
PHASE_NAMES_KINDS = {"names"}
|
||||
PHASE_DB_KINDS = {"db"}
|
||||
PHASE_DIALOGUE_KINDS = {"map", "common", "gamedat", "txt", "txt-dir"}
|
||||
|
||||
# WolfDawn's inject commands print e.g. "applied 91 translation(s) (0 untranslated,
|
||||
# 0 drifted); wrote <path>". We parse the counts to distinguish a real inject from a
|
||||
# silent no-op (exit 0 but 0 applied because the base was already translated).
|
||||
|
|
@ -125,7 +141,7 @@ _WOLF_GLOSSARY_PROMPT = (
|
|||
" - Game.dat.json — game/system strings (title, terms).\n"
|
||||
" - <Map>.mps.json — per-map events: the main story dialogue (can be large).\n"
|
||||
" - Evtext.json — external event text, when present.\n"
|
||||
" - names.json — item/skill/enemy value names (curated separately in Step 3; do NOT list them).\n"
|
||||
" - names.json — item/skill/enemy value names (curated separately in Step 2; do NOT list them).\n"
|
||||
"</data_format>\n"
|
||||
"\n"
|
||||
"<file_strategy>\n"
|
||||
|
|
@ -157,7 +173,7 @@ _WOLF_GLOSSARY_PROMPT = (
|
|||
"- Include: faction/organisation names, locations mentioned in dialogue but not on maps, "
|
||||
"unique magic systems, lore titles, recurring in-universe concepts.\n"
|
||||
"- Exclude: skill names, item names, weapon/armour names (curated separately via "
|
||||
"names.json in Step 3). Skip generic RPG words. Do not repeat character names here.\n"
|
||||
"names.json in Step 2). Skip generic RPG words. Do not repeat character names here.\n"
|
||||
"</rules>\n"
|
||||
"\n"
|
||||
"<output_format>\n"
|
||||
|
|
@ -346,6 +362,18 @@ class WolfWorkflowTab(QWidget):
|
|||
except Exception:
|
||||
return None
|
||||
|
||||
def _manifest_kinds(self) -> dict:
|
||||
"""Return ``{json_filename: kind}`` from the manifest (used to pick which
|
||||
files a translation phase should translate)."""
|
||||
out: dict[str, str] = {}
|
||||
manifest = self._read_manifest()
|
||||
if manifest:
|
||||
for entry in manifest.get("entries", []):
|
||||
name, kind = entry.get("json"), entry.get("kind")
|
||||
if name and kind:
|
||||
out[name] = kind
|
||||
return out
|
||||
|
||||
# ─────────────────────────── pristine originals ──────────────────────────
|
||||
# WolfDawn injects by locating each Japanese `source` string inside the base
|
||||
# binary and replacing it. That only works against the *original* (untranslated)
|
||||
|
|
@ -469,11 +497,12 @@ class WolfWorkflowTab(QWidget):
|
|||
QTabBar::tab:hover:!selected { background-color: #2d2d30; color: #cccccc; }
|
||||
""")
|
||||
|
||||
# Names curation comes before Translate so Phase 0 can seed vocab.txt.
|
||||
_tab_defs = [
|
||||
("0 Project", self._build_step0),
|
||||
("1 Glossary", self._build_step1_glossary),
|
||||
("2 Translate", self._build_step2_translate),
|
||||
("3 Names", self._build_step3_names),
|
||||
("2 Names", self._build_step3_names),
|
||||
("3 Translate", self._build_step2_translate),
|
||||
("4 Inject", self._build_step4_inject),
|
||||
("5 Package", self._build_step5_package),
|
||||
("6 Saves", self._build_step6_saves),
|
||||
|
|
@ -893,9 +922,9 @@ class WolfWorkflowTab(QWidget):
|
|||
if removed:
|
||||
log(f"Cleared {removed} existing file(s) from files/")
|
||||
|
||||
# Stage everything into files/. names.json is included so it can be
|
||||
# translated in Step 3, but the Step 2 bulk run skips it and the WolfDawn
|
||||
# module only translates the note categories marked safe in Step 3.
|
||||
# Stage everything into files/. names.json is included so Phase 0 can
|
||||
# translate it, but the WolfDawn module only translates the note
|
||||
# categories marked safe in Step 2 (Names).
|
||||
copied = 0
|
||||
for entry in manifest_entries:
|
||||
src = work_dir / entry["json"]
|
||||
|
|
@ -905,7 +934,7 @@ class WolfWorkflowTab(QWidget):
|
|||
|
||||
return True, (
|
||||
f"Extracted {len(manifest_entries)} document(s) and imported {copied} into files/. "
|
||||
"Go to Step 2 to translate (names.json is handled later, in Step 3)."
|
||||
"Curate names in Step 2, then translate in Step 3."
|
||||
)
|
||||
|
||||
self._run_task(task)
|
||||
|
|
@ -950,7 +979,7 @@ class WolfWorkflowTab(QWidget):
|
|||
layout.addWidget(self._desc(
|
||||
"vocab.txt is the project-wide glossary used by every translation batch to keep "
|
||||
"character names, speech register, honorifics, and lore terms consistent. Build it "
|
||||
"before Step 2 so the AI already knows every character and term while translating. "
|
||||
"before translating (Step 3) so the AI already knows every character and term. "
|
||||
"Copy the prompt below into Cursor or Copilot Chat with the extracted files/ JSON "
|
||||
"open, let it analyse the game, then paste its output into the editor and save."
|
||||
))
|
||||
|
|
@ -991,8 +1020,9 @@ class WolfWorkflowTab(QWidget):
|
|||
layout.addLayout(vrow)
|
||||
|
||||
note = self._desc(
|
||||
"Item / skill / enemy value names (names.json) are handled later in Step 3 - they "
|
||||
"are not part of vocab.txt and must not all be translated."
|
||||
"You don't need to add item / skill / enemy value names here: they're curated in "
|
||||
"Step 2 and the safe ones are harvested into vocab.txt automatically during Phase 0. "
|
||||
"Focus this glossary on characters and worldbuilding terms."
|
||||
)
|
||||
layout.addWidget(note)
|
||||
|
||||
|
|
@ -1016,24 +1046,65 @@ class WolfWorkflowTab(QWidget):
|
|||
except Exception as exc:
|
||||
self._log(f"❌ Could not save vocab.txt: {exc}")
|
||||
|
||||
# ── Step 2: Translate ──────────────────────────────────────────────────────
|
||||
# ── Step 3: Translate (phased) ─────────────────────────────────────────────
|
||||
|
||||
def _build_step2_translate(self, layout: QVBoxLayout):
|
||||
layout.addWidget(_make_section_label("Step 2 · Translate"))
|
||||
layout.addWidget(_make_section_label("Step 3 · Translate (phased)"))
|
||||
layout.addWidget(self._desc(
|
||||
"Sends the extracted files in files/ to the Translation tab using the "
|
||||
"Wolf RPG (WolfDawn) module and starts translating. Only the 'text' fields are "
|
||||
"filled in; 'source' is preserved so injection can verify each line."
|
||||
"Sends the extracted files in files/ to the Translation tab using the Wolf RPG "
|
||||
"(WolfDawn) module. Only the 'text' fields are filled in; 'source' is preserved so "
|
||||
"injection can verify each line. Run the phases in order: Phase 0 translates the safe "
|
||||
"names and writes them into vocab.txt, so Phases 1 and 2 translate descriptions and "
|
||||
"dialogue with consistent item / skill / character names (RPGMaker's DB-first strategy)."
|
||||
))
|
||||
|
||||
self._add_tl_mode_selector(layout)
|
||||
|
||||
self._add_phase_buttons(layout)
|
||||
|
||||
self._add_speaker_options(layout)
|
||||
self._add_wrap_options(layout)
|
||||
|
||||
btn = self._register(_make_btn("Translate all files now", "#00a86b"))
|
||||
btn.clicked.connect(lambda: self._navigate_to_translation(auto_start=True))
|
||||
layout.addWidget(btn)
|
||||
def _add_phase_buttons(self, layout: QVBoxLayout):
|
||||
"""Three ordered translation phases selected by manifest kind."""
|
||||
layout.addWidget(_make_hr())
|
||||
layout.addWidget(self._subheading("Phase 0 · Names → vocab.txt"))
|
||||
layout.addWidget(self._desc(
|
||||
"Translates only the safe name categories you approved in Step 2 and harvests them into "
|
||||
"vocab.txt (grouped by category, e.g. \"Weapon · 武器\"). Curate safe categories in Step 2 "
|
||||
"first, or nothing is translated. Also runnable from Step 2."
|
||||
))
|
||||
p0 = self._register(_make_btn("▶ Phase 0 · Translate names", "#00a86b"))
|
||||
p0.clicked.connect(
|
||||
lambda: self._navigate_to_translation(kinds=PHASE_NAMES_KINDS, auto_start=True)
|
||||
)
|
||||
layout.addWidget(p0)
|
||||
|
||||
layout.addWidget(_make_hr())
|
||||
layout.addWidget(self._subheading("Phase 1 · Database text"))
|
||||
layout.addWidget(self._desc(
|
||||
"Item / skill / state descriptions and system messages from DataBase.project and "
|
||||
"CDataBase.project. Run after Phase 0 so names stay consistent."
|
||||
))
|
||||
p1 = self._register(_make_btn("▶ Phase 1 · Translate database text", "#00a86b"))
|
||||
p1.clicked.connect(
|
||||
lambda: self._navigate_to_translation(kinds=PHASE_DB_KINDS, auto_start=True)
|
||||
)
|
||||
layout.addWidget(p1)
|
||||
|
||||
layout.addWidget(_make_hr())
|
||||
layout.addWidget(self._subheading("Phase 2 · Dialogue & events"))
|
||||
layout.addWidget(self._desc(
|
||||
"The bulk of the script: map events (.mps), CommonEvent, Game.dat and Evtext. Run last "
|
||||
"so it benefits from the names and terms translated in the earlier phases."
|
||||
))
|
||||
p2 = self._register(_make_btn("▶ Phase 2 · Translate dialogue & events", "#00a86b"))
|
||||
p2.clicked.connect(
|
||||
lambda: self._navigate_to_translation(kinds=PHASE_DIALOGUE_KINDS, auto_start=True)
|
||||
)
|
||||
layout.addWidget(p2)
|
||||
|
||||
layout.addWidget(_make_hr())
|
||||
open_btn = self._register(_make_btn("Open Translation tab (no auto-start)", "#3a3a3a"))
|
||||
open_btn.clicked.connect(lambda: self._navigate_to_translation(auto_start=False))
|
||||
layout.addWidget(open_btn)
|
||||
|
|
@ -1217,8 +1288,18 @@ class WolfWorkflowTab(QWidget):
|
|||
return BATCH_MODE_LABEL
|
||||
return "Translate"
|
||||
|
||||
def _navigate_to_translation(self, only: str | None = None, auto_start: bool = False):
|
||||
"""Switch to the Translation tab, select the WolfDawn module, and check files."""
|
||||
def _navigate_to_translation(
|
||||
self,
|
||||
only: str | None = None,
|
||||
kinds: set[str] | None = None,
|
||||
auto_start: bool = False,
|
||||
):
|
||||
"""Switch to the Translation tab, select the WolfDawn module, and check files.
|
||||
|
||||
Selection precedence: ``only`` (a single filename) > ``kinds`` (a phase's
|
||||
manifest kinds, e.g. ``{"db"}``) > legacy bulk (everything except
|
||||
names.json).
|
||||
"""
|
||||
pw = self.parent_window
|
||||
tt = getattr(pw, "translation_tab", None) if pw else None
|
||||
if tt is None:
|
||||
|
|
@ -1243,6 +1324,7 @@ class WolfWorkflowTab(QWidget):
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
kind_map = self._manifest_kinds() if kinds is not None else {}
|
||||
try:
|
||||
tt.refresh_file_lists()
|
||||
fl = tt.file_list
|
||||
|
|
@ -1250,10 +1332,13 @@ class WolfWorkflowTab(QWidget):
|
|||
item = fl.item(idx)
|
||||
name = item.text()
|
||||
if only is not None:
|
||||
# Targeted run (e.g. Step 3 names): check exactly that file.
|
||||
# Targeted run: check exactly that file.
|
||||
check = name == only
|
||||
elif kinds is not None:
|
||||
# Phase run: check files whose manifest kind is in this phase.
|
||||
check = kind_map.get(name) in kinds
|
||||
else:
|
||||
# Bulk run: everything except names.json (handled in Step 3).
|
||||
# Bulk run: everything except names.json.
|
||||
check = name != NAMES_JSON
|
||||
item.setCheckState(Qt.Checked if check else Qt.Unchecked)
|
||||
except Exception:
|
||||
|
|
@ -1271,10 +1356,10 @@ class WolfWorkflowTab(QWidget):
|
|||
lambda: tt.start_translation(skip_confirm=True) if tt is not None else None,
|
||||
)
|
||||
|
||||
# ── Step 3: Names ──────────────────────────────────────────────────────────
|
||||
# ── Step 2: Names ──────────────────────────────────────────────────────────
|
||||
|
||||
def _build_step3_names(self, layout: QVBoxLayout):
|
||||
layout.addWidget(_make_section_label("Step 3 · Curate Name Values (names.json)"))
|
||||
layout.addWidget(_make_section_label("Step 2 · Curate Name Values (names.json)"))
|
||||
layout.addWidget(self._desc(
|
||||
"names.json lists every item / skill / enemy / variable value name WolfDawn found. "
|
||||
"Translating all of them WILL break the game: many double as logic keys (compared by "
|
||||
|
|
@ -1345,13 +1430,17 @@ class WolfWorkflowTab(QWidget):
|
|||
self._reload_notes_list()
|
||||
|
||||
layout.addWidget(_make_hr())
|
||||
layout.addWidget(self._subheading("3 · Translate the safe names"))
|
||||
layout.addWidget(self._subheading("3 · Translate the safe names (Phase 0)"))
|
||||
layout.addWidget(self._desc(
|
||||
"After saving, translate only the approved categories. The WolfDawn module skips every "
|
||||
"other name (leaving it identical to the source), then Step 4 injects the result."
|
||||
"other name (leaving it identical to the source) and harvests the translated names into "
|
||||
"vocab.txt (grouped by category) so the later Translate phases stay consistent. This is "
|
||||
"the same as Phase 0 in Step 3; run it here first, or from the Translate step."
|
||||
))
|
||||
tl_btn = self._register(_make_btn("Translate safe names now", "#00a86b"))
|
||||
tl_btn.clicked.connect(lambda: self._navigate_to_translation(only=NAMES_JSON, auto_start=True))
|
||||
tl_btn = self._register(_make_btn("Translate safe names now (Phase 0)", "#00a86b"))
|
||||
tl_btn.clicked.connect(
|
||||
lambda: self._navigate_to_translation(kinds=PHASE_NAMES_KINDS, auto_start=True)
|
||||
)
|
||||
layout.addWidget(tl_btn)
|
||||
|
||||
def _copy_wolf_names_prompt(self):
|
||||
|
|
@ -1485,7 +1574,7 @@ class WolfWorkflowTab(QWidget):
|
|||
layout.addWidget(_make_section_label("Step 4 · Inject Translations"))
|
||||
layout.addWidget(self._desc(
|
||||
"Writes the translated text back into the game's Data/ binaries with WolfDawn, "
|
||||
"byte-exact. The curated name values from Step 3 are applied across Data/ (only the "
|
||||
"byte-exact. The curated name values from Step 2 are applied across Data/ (only the "
|
||||
"entries you translated change). Lines whose inline codes changed are skipped and "
|
||||
"reported (unless you allow drift)."
|
||||
))
|
||||
|
|
@ -1516,7 +1605,7 @@ class WolfWorkflowTab(QWidget):
|
|||
"For iterating: after translating a few files, tick just those here and inject them "
|
||||
"straight into the game's Data/ so you can review the git diff in the game project and "
|
||||
"test in-game. Only files that already have a translation in translated/ are listed. "
|
||||
f"{NAMES_JSON} appears here once you've curated and saved it in Step 3; tick it to "
|
||||
f"{NAMES_JSON} appears here once you've curated and saved it in Step 2; tick it to "
|
||||
"apply the safe name values across Data/."
|
||||
))
|
||||
|
||||
|
|
@ -1550,7 +1639,7 @@ class WolfWorkflowTab(QWidget):
|
|||
layout.addWidget(_make_hr())
|
||||
layout.addWidget(self._subheading("Full inject"))
|
||||
layout.addWidget(self._desc(
|
||||
"Injects every extracted document and applies the curated name values (Step 3) "
|
||||
"Injects every extracted document and applies the curated name values (Step 2) "
|
||||
"across Data/. Use this once translation is complete before packaging in Step 5."
|
||||
))
|
||||
inject_btn = self._register(_make_btn("Inject all translations", "#00a86b"))
|
||||
|
|
@ -1566,8 +1655,8 @@ class WolfWorkflowTab(QWidget):
|
|||
return None
|
||||
|
||||
def _on_step_changed(self, idx: int):
|
||||
# Index 3 == Names: refresh the note-category checklist from names.json.
|
||||
if idx == 3:
|
||||
# Index 2 == Names: refresh the note-category checklist from names.json.
|
||||
if idx == 2:
|
||||
self._reload_notes_list()
|
||||
# Index 4 == Inject: keep the quick-inject picker in sync with translated/.
|
||||
elif idx == 4:
|
||||
|
|
@ -1597,7 +1686,7 @@ class WolfWorkflowTab(QWidget):
|
|||
self.inject_list.addItem(item)
|
||||
listed += 1
|
||||
if listed == 0:
|
||||
item = QListWidgetItem("No translated files in translated/ yet — run Step 2.")
|
||||
item = QListWidgetItem("No translated files in translated/ yet — run Step 3.")
|
||||
item.setFlags(Qt.ItemIsEnabled)
|
||||
self.inject_list.addItem(item)
|
||||
|
||||
|
|
@ -1643,7 +1732,7 @@ class WolfWorkflowTab(QWidget):
|
|||
if p:
|
||||
json_files.append(str(p))
|
||||
if not json_files:
|
||||
return False, "No translated JSON found. Run Step 2 first."
|
||||
return False, "No translated JSON found. Run Step 3 first."
|
||||
res = wolfdawn.names_check(json_files, log_fn=log)
|
||||
if res.returncode == 0:
|
||||
return True, "Name usage is consistent across files."
|
||||
|
|
@ -1660,7 +1749,7 @@ class WolfWorkflowTab(QWidget):
|
|||
are applied across Data/ only when NAMES_JSON is included.
|
||||
|
||||
Name values are applied only when translated/names.json exists (i.e. the
|
||||
user curated it in Step 3); otherwise names are left untouched.
|
||||
user curated it in Step 2); otherwise names are left untouched.
|
||||
"""
|
||||
if not self._require_manifest():
|
||||
return
|
||||
|
|
@ -1738,7 +1827,7 @@ class WolfWorkflowTab(QWidget):
|
|||
log(f" ⚠ inject exit {res.returncode} for {entry['json']}")
|
||||
|
||||
# Curated name values: apply across the whole data dir, but only when a
|
||||
# curated translated/names.json exists (Step 3). For a quick inject, only
|
||||
# curated translated/names.json exists (Step 2). For a quick inject, only
|
||||
# do this if the user explicitly ticked the names file, since it rewrites
|
||||
# many binaries and would add noise to the git diff otherwise.
|
||||
names_entry = next((e for e in entries if e["kind"] == "names"), None)
|
||||
|
|
|
|||
|
|
@ -23,7 +23,10 @@ names) that break the game if translated. So for ``kind == "names"`` documents,
|
|||
only entries whose ``note`` is in the opt-in safe list (``SAFE_NOTES``, from
|
||||
``data/wolf_safe_notes.json`` via :mod:`util.wolf_names`) are translated; the rest
|
||||
are left as source. The list is empty by default, so nothing is translated until
|
||||
the user opts categories in from the workflow.
|
||||
the user opts categories in from the workflow. After translating names.json, the
|
||||
safe entries are harvested into ``vocab.txt`` (grouped by ``note``, with bilingual
|
||||
headers) so later phases (DB descriptions, dialogue) keep item / skill / term
|
||||
names consistent - the WOLF equivalent of RPGMaker's DB-first glossary seeding.
|
||||
|
||||
Text wrapping: translated dialogue is re-wrapped to a character width (like the
|
||||
RPGMaker module, via :mod:`util.dazedwrap`) so English fits WOLF's message box.
|
||||
|
|
@ -61,6 +64,7 @@ from util.translation import (
|
|||
calculateCost,
|
||||
)
|
||||
from util import speakers as wolf_speakers
|
||||
from util import vocab as wolf_vocab
|
||||
from util import wolf_names
|
||||
|
||||
# Globals (mirror the other engine modules; populated from .env at import time)
|
||||
|
|
@ -131,13 +135,17 @@ TRANSLATION_CONFIG = TranslationConfig(
|
|||
|
||||
def handleWolfDawn(filename, estimate):
|
||||
"""Entry point used by the CLI/GUI dispatchers. Returns a summary string or 'Fail'."""
|
||||
global ESTIMATE, TOKENS, FILENAME, SAFE_NOTES, SPEAKER_CONFIG
|
||||
global ESTIMATE, TOKENS, FILENAME, SAFE_NOTES, SPEAKER_CONFIG, VOCAB
|
||||
ESTIMATE = estimate
|
||||
FILENAME = filename
|
||||
# Re-read workflow-configured settings so edits made this session take effect
|
||||
# even when translation runs in-process (the module import is cached).
|
||||
SAFE_NOTES = wolf_names.load_safe_notes()
|
||||
SPEAKER_CONFIG = wolf_speakers.load_config()
|
||||
# Reload the glossary so a later phase (DB text / dialogue) picks up names
|
||||
# that an earlier Phase 0 (names) harvested into vocab.txt.
|
||||
VOCAB = VOCAB_PATH.read_text(encoding="utf-8")
|
||||
TRANSLATION_CONFIG.vocab = VOCAB
|
||||
|
||||
start = time.time()
|
||||
translatedData = openFiles(filename)
|
||||
|
|
@ -303,9 +311,40 @@ def parseDocument(data, filename):
|
|||
else:
|
||||
entry["text"] = _wrap_plain(text, is_firstline)
|
||||
|
||||
# Phase 0 feeds the glossary: harvest the just-translated safe name values
|
||||
# into vocab.txt (grouped by note) so the DB-text and dialogue phases keep
|
||||
# item/skill/term names consistent. Mirrors the RPGMaker DB-first strategy.
|
||||
if is_names and not ESTIMATE:
|
||||
_harvest_names_to_vocab(data)
|
||||
|
||||
return [data, totalTokens, None]
|
||||
|
||||
|
||||
def _harvest_names_to_vocab(data):
|
||||
"""Write translated safe names.json entries into vocab.txt, grouped by note.
|
||||
|
||||
Each note category becomes a bilingual ``# English · 日本語`` section (English
|
||||
sourced from the staged DB labels, else a static map, else JP-only). Only
|
||||
safe notes with an actual translation are written; the base vocab section is
|
||||
preserved by :func:`util.vocab.update_vocab_section`.
|
||||
"""
|
||||
try:
|
||||
db_labels = wolf_names.derive_db_labels("files")
|
||||
by_note: dict[str, list] = {}
|
||||
for entry in data.get("names") or []:
|
||||
note = entry.get("note", "")
|
||||
if not wolf_names.is_note_safe(note, SAFE_NOTES):
|
||||
continue
|
||||
src, dst = entry.get("source"), entry.get("text")
|
||||
if not isinstance(src, str) or not isinstance(dst, str):
|
||||
continue
|
||||
by_note.setdefault(note, []).append((src, dst))
|
||||
for note, pairs in by_note.items():
|
||||
wolf_vocab.update_vocab_section(wolf_names.note_header(note, db_labels), pairs)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
def getResultString(translatedData, translationTime, filename):
|
||||
"""Format the per-file / total cost + status line for the console log."""
|
||||
cost = calculateCost(translatedData[1][0], translatedData[1][1], MODEL)
|
||||
|
|
|
|||
68
tests/test_vocab.py
Normal file
68
tests/test_vocab.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
"""Tests for util.vocab (game-specific glossary helpers)."""
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import util.vocab as vocab
|
||||
|
||||
|
||||
class TestUpdateVocabSection(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.vocab_path = Path(self.tmp.name) / "vocab.txt"
|
||||
self.base_path = Path(self.tmp.name) / "vocab_base.txt"
|
||||
self.base_path.write_text("# Base\nhello (hello)\n", encoding="utf-8")
|
||||
self.vocab_path.write_text(
|
||||
"# Game Characters\nAlice (Alice)\n\n"
|
||||
+ vocab.BASE_SEPARATOR
|
||||
+ "# Base\nhello (hello)\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
self._p_vocab = patch.object(vocab, "VOCAB_PATH", self.vocab_path)
|
||||
self._p_base = patch.object(vocab, "VOCAB_BASE_PATH", self.base_path)
|
||||
self._p_vocab.start()
|
||||
self._p_base.start()
|
||||
|
||||
def tearDown(self):
|
||||
self._p_vocab.stop()
|
||||
self._p_base.stop()
|
||||
self.tmp.cleanup()
|
||||
|
||||
def test_inserts_section_above_base_separator(self):
|
||||
vocab.update_vocab_section("Weapon · 武器", [("剣", "Sword")])
|
||||
|
||||
text = self.vocab_path.read_text(encoding="utf-8")
|
||||
self.assertIn("# Weapon · 武器\n剣 (Sword)", text)
|
||||
self.assertIn(vocab.BASE_SEPARATOR, text)
|
||||
self.assertIn("# Base\nhello (hello)", text)
|
||||
# Game section must precede the base separator.
|
||||
self.assertLess(text.index("# Weapon · 武器"), text.index(vocab.BASE_SEPARATOR))
|
||||
|
||||
def test_replaces_existing_section(self):
|
||||
vocab.update_vocab_section("Game Characters", [("Bob (Bob)", "Robert")])
|
||||
vocab.update_vocab_section("Game Characters", [("Alice", "Alicia")])
|
||||
|
||||
text = self.vocab_path.read_text(encoding="utf-8")
|
||||
self.assertIn("Alice (Alicia)", text)
|
||||
self.assertNotIn("Bob (Robert)", text)
|
||||
self.assertEqual(text.count("# Game Characters"), 1)
|
||||
|
||||
def test_skips_noop_pairs(self):
|
||||
before = self.vocab_path.read_text(encoding="utf-8")
|
||||
vocab.update_vocab_section("Items", [("Potion", "Potion"), ("", "X")])
|
||||
after = self.vocab_path.read_text(encoding="utf-8")
|
||||
self.assertEqual(before, after)
|
||||
|
||||
def test_dedupes_by_source_last_wins(self):
|
||||
vocab.update_vocab_section("Skill · 技能", [("ヒール", "Heal"), ("ヒール", "Cure")])
|
||||
|
||||
text = self.vocab_path.read_text(encoding="utf-8")
|
||||
self.assertIn("ヒール (Cure)", text)
|
||||
self.assertNotIn("ヒール (Heal)", text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -53,5 +53,46 @@ class TestIsNoteSafe(unittest.TestCase):
|
|||
self.assertFalse(wn.is_note_safe("", []))
|
||||
|
||||
|
||||
class TestNoteHeader(unittest.TestCase):
|
||||
def test_static_fallback_is_bilingual(self):
|
||||
self.assertEqual(wn.note_header("武器"), "Weapon · 武器")
|
||||
|
||||
def test_live_db_label_wins_over_static(self):
|
||||
labels = {"武器": "Blade"}
|
||||
self.assertEqual(wn.note_header("武器", labels), "Blade · 武器")
|
||||
|
||||
def test_unknown_note_stays_japanese_only(self):
|
||||
self.assertEqual(wn.note_header("■MOBセリフ"), "■MOBセリフ")
|
||||
|
||||
|
||||
class TestDeriveDbLabels(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.dir = Path(self._tmp.name)
|
||||
|
||||
def tearDown(self):
|
||||
self._tmp.cleanup()
|
||||
|
||||
def test_parses_bilingual_typenames_from_db_files(self):
|
||||
import json
|
||||
|
||||
(self.dir / "DataBase.project.json").write_text(
|
||||
json.dumps({
|
||||
"kind": "db",
|
||||
"groups": [
|
||||
{"typeName": "Weapon · 武器", "lines": []},
|
||||
{"typeName": "Skill · 技能", "lines": []},
|
||||
{"typeName": "■MOBセリフ", "lines": []}, # no separator -> skipped
|
||||
],
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
labels = wn.derive_db_labels(self.dir)
|
||||
self.assertEqual(labels, {"武器": "Weapon", "技能": "Skill"})
|
||||
|
||||
def test_missing_dir_returns_empty(self):
|
||||
self.assertEqual(wn.derive_db_labels(self.dir / "nope"), {})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -38,19 +38,30 @@ class _WolfTranslateHarness:
|
|||
|
||||
def __init__(self):
|
||||
self.captured = []
|
||||
# Captured (category, pairs) from the names -> vocab.txt harvest, so tests
|
||||
# can assert on it without writing to the real data/vocab.txt.
|
||||
self.vocab_writes = []
|
||||
|
||||
def run(self, data, filename="doc.json", estimate=False, safe_notes=None):
|
||||
def translate(text, history, history_ctx=None):
|
||||
self.captured.append(copy.deepcopy(text))
|
||||
return _mock_translate(text, history, history_ctx)
|
||||
|
||||
def capture_vocab(category, pairs):
|
||||
self.vocab_writes.append((category, list(pairs)))
|
||||
|
||||
orig_t = wd.translateAI
|
||||
orig_estimate = wd.ESTIMATE
|
||||
orig_wrap = wd.WRAP
|
||||
orig_notes = wd.SAFE_NOTES
|
||||
orig_update = wd.wolf_vocab.update_vocab_section
|
||||
orig_labels = wd.wolf_names.derive_db_labels
|
||||
wd.translateAI = translate
|
||||
wd.ESTIMATE = estimate
|
||||
wd.WRAP = False # keep write-back byte-faithful; wrapping tested separately
|
||||
# Never touch the real glossary / DB files during tests.
|
||||
wd.wolf_vocab.update_vocab_section = capture_vocab
|
||||
wd.wolf_names.derive_db_labels = lambda _p: {}
|
||||
if safe_notes is not None:
|
||||
wd.SAFE_NOTES = safe_notes
|
||||
try:
|
||||
|
|
@ -62,6 +73,8 @@ class _WolfTranslateHarness:
|
|||
wd.ESTIMATE = orig_estimate
|
||||
wd.WRAP = orig_wrap
|
||||
wd.SAFE_NOTES = orig_notes
|
||||
wd.wolf_vocab.update_vocab_section = orig_update
|
||||
wd.wolf_names.derive_db_labels = orig_labels
|
||||
|
||||
|
||||
MAP_DOC = {
|
||||
|
|
@ -168,6 +181,20 @@ class TestTranslationWriteback(unittest.TestCase):
|
|||
for entry in data["names"]:
|
||||
self.assertEqual(entry["text"], entry["source"])
|
||||
|
||||
def test_names_harvest_to_vocab(self):
|
||||
# Phase 0 seeds vocab.txt: only translated safe-note pairs are harvested,
|
||||
# under a bilingual header (English from the static NOTE_EN fallback).
|
||||
harness = _WolfTranslateHarness()
|
||||
(_data, _t, err), _c = harness.run(NAMES_DOC, "names.json", safe_notes=["武器"])
|
||||
self.assertIsNone(err)
|
||||
self.assertEqual(harness.vocab_writes, [("Weapon · 武器", [("剣", "EN_剣")])])
|
||||
|
||||
def test_names_harvest_skipped_in_estimate(self):
|
||||
# Estimate mode must not seed the glossary.
|
||||
harness = _WolfTranslateHarness()
|
||||
harness.run(NAMES_DOC, "names.json", estimate=True, safe_notes=["武器"])
|
||||
self.assertEqual(harness.vocab_writes, [])
|
||||
|
||||
def test_txtdir_translates(self):
|
||||
(data, _t, err), _c = _WolfTranslateHarness().run(TXTDIR_DOC, "Evtext.json")
|
||||
self.assertIsNone(err)
|
||||
|
|
|
|||
|
|
@ -17,6 +17,10 @@ otherwise the base section would not be stripped on reload.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
|
||||
from util.paths import VOCAB_BASE_PATH, VOCAB_PATH
|
||||
|
||||
BASE_SEPARATOR = (
|
||||
|
|
@ -25,6 +29,10 @@ BASE_SEPARATOR = (
|
|||
|
||||
_EMPTY_PLACEHOLDER = "# Add character glossary entries here\n"
|
||||
|
||||
# Guards the read-modify-write in update_vocab_section against concurrent
|
||||
# translation file-threads clobbering each other's sections.
|
||||
_VOCAB_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def read_game_vocab() -> str:
|
||||
"""Return the game-specific portion of ``vocab.txt`` (base section stripped)."""
|
||||
|
|
@ -45,3 +53,77 @@ def write_game_vocab(game_text: str) -> None:
|
|||
)
|
||||
combined = game_text + "\n\n" + BASE_SEPARATOR + base_text
|
||||
VOCAB_PATH.write_text(combined, encoding="utf-8")
|
||||
|
||||
|
||||
def _norm(s: str) -> str:
|
||||
"""Normalise for no-op detection: collapse whitespace and case-fold."""
|
||||
if s is None:
|
||||
return ""
|
||||
return re.sub(r"\s+", " ", str(s)).strip().casefold()
|
||||
|
||||
|
||||
def update_vocab_section(category: str, pairs) -> None:
|
||||
"""Insert or replace a ``# {category}`` section in the game-specific vocab.
|
||||
|
||||
Mirrors the RPGMaker auto-glossary behaviour (translated DB names feed
|
||||
``vocab.txt`` so later phases stay consistent), but always writes *above*
|
||||
the auto-appended base section (:data:`BASE_SEPARATOR`) so the base vocab is
|
||||
preserved and not stripped on the next :func:`read_game_vocab`.
|
||||
|
||||
- ``category``: section header text, e.g. ``"Weapon · 武器"``.
|
||||
- ``pairs``: iterable of ``(source, translated)``. Deduped by source (last
|
||||
wins); no-ops (empty translation or unchanged after normalisation) are
|
||||
dropped. When nothing survives filtering the file is left untouched.
|
||||
"""
|
||||
dedup: dict[str, str] = {}
|
||||
for src, dst in pairs:
|
||||
if not src:
|
||||
continue
|
||||
if dst is None or _norm(dst) == "" or _norm(dst) == _norm(src):
|
||||
continue
|
||||
dedup[str(src)] = str(dst)
|
||||
if not dedup:
|
||||
return
|
||||
|
||||
with _VOCAB_LOCK:
|
||||
existing = VOCAB_PATH.read_text(encoding="utf-8") if VOCAB_PATH.is_file() else ""
|
||||
|
||||
# Keep the auto-appended base section (separator + base vocab) intact.
|
||||
idx = existing.find(BASE_SEPARATOR)
|
||||
if idx != -1:
|
||||
game_part = existing[:idx]
|
||||
base_part = existing[idx:]
|
||||
else:
|
||||
game_part = existing
|
||||
base_part = ""
|
||||
|
||||
block_lines = [f"{src} ({dst})" for src, dst in dedup.items()]
|
||||
new_block = f"# {category}\n" + "\n".join(block_lines) + "\n\n"
|
||||
|
||||
# Match this category's section up to the next '#' header or end of the
|
||||
# game portion. Handles '#Cat', '# Cat', '## Cat', etc.
|
||||
pattern = re.compile(
|
||||
rf"^[\t ]*#+\s*{re.escape(category)}\s*$\r?\n.*?(?=^[\t ]*#|\Z)",
|
||||
re.MULTILINE | re.DOTALL,
|
||||
)
|
||||
if pattern.search(game_part):
|
||||
new_game = pattern.sub(lambda _m: new_block, game_part, count=1)
|
||||
else:
|
||||
new_game = game_part.rstrip("\n")
|
||||
if new_game:
|
||||
new_game += "\n\n"
|
||||
new_game += new_block
|
||||
|
||||
if base_part:
|
||||
combined = new_game.rstrip("\n") + "\n\n" + base_part
|
||||
else:
|
||||
combined = new_game
|
||||
|
||||
if combined == existing:
|
||||
return
|
||||
|
||||
tmp_path = VOCAB_PATH.with_suffix(
|
||||
VOCAB_PATH.suffix + f".{os.getpid()}.{threading.get_ident()}.tmp"
|
||||
)
|
||||
tmp_path.write_text(combined, encoding="utf-8")
|
||||
os.replace(tmp_path, VOCAB_PATH)
|
||||
|
|
|
|||
|
|
@ -28,6 +28,30 @@ from util.paths import DATA_DIR
|
|||
|
||||
SAFE_NOTES_PATH = DATA_DIR / "wolf_safe_notes.json"
|
||||
|
||||
# WolfDawn labels standard database types bilingually in a document's group
|
||||
# ``typeName`` (e.g. ``"Weapon · 武器"``). names.json ``note`` values are the JP
|
||||
# half only, so this static map provides an English label for the common,
|
||||
# safe-to-translate categories when the live DB labels are unavailable. Games
|
||||
# with custom categories fall back to a JP-only header.
|
||||
NOTE_EN: dict[str, str] = {
|
||||
"武器": "Weapon",
|
||||
"防具": "Armor",
|
||||
"技能": "Skill",
|
||||
"アイテム": "Item",
|
||||
"状態設定": "State Setting",
|
||||
"用語設定": "Term Setting",
|
||||
"戦闘コマンド": "Battle Command",
|
||||
"システム設定": "System Setting",
|
||||
"属性名の設定": "Attribute",
|
||||
"主人公ステータス": "Hero Status",
|
||||
"敵グループ": "Enemy Group",
|
||||
"敵キャラ個体データ": "Enemy Data",
|
||||
}
|
||||
|
||||
# WolfDawn joins the English and Japanese halves of a database label with this
|
||||
# separator, e.g. ``"Weapon · 武器"``.
|
||||
_LABEL_SEP = " · "
|
||||
|
||||
|
||||
def load_safe_notes() -> list[str]:
|
||||
"""Return the saved list of ``note`` categories that are safe to translate."""
|
||||
|
|
@ -61,3 +85,50 @@ def is_note_safe(note: str, safe_notes) -> bool:
|
|||
if not safe_notes:
|
||||
return False
|
||||
return note in safe_notes
|
||||
|
||||
|
||||
def derive_db_labels(files_dir) -> dict[str, str]:
|
||||
"""Build a ``{japanese_note: english}`` map from staged WolfDawn ``db`` files.
|
||||
|
||||
WolfDawn tags each database group with a bilingual ``typeName`` (e.g.
|
||||
``"Weapon · 武器"``). Splitting on the separator recovers the game-accurate
|
||||
English label for a names.json ``note`` (the JP half), so harvested vocab
|
||||
sections can be headed bilingually without hardcoding categories per game.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
labels: dict[str, str] = {}
|
||||
try:
|
||||
base = Path(files_dir)
|
||||
if not base.is_dir():
|
||||
return labels
|
||||
for path in sorted(base.glob("*.project.json")):
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8-sig"))
|
||||
except Exception:
|
||||
continue
|
||||
if data.get("kind") != "db":
|
||||
continue
|
||||
for group in data.get("groups") or []:
|
||||
type_name = group.get("typeName") or ""
|
||||
if _LABEL_SEP in type_name:
|
||||
english, _, japanese = type_name.partition(_LABEL_SEP)
|
||||
english, japanese = english.strip(), japanese.strip()
|
||||
if english and japanese and japanese not in labels:
|
||||
labels[japanese] = english
|
||||
except Exception:
|
||||
pass
|
||||
return labels
|
||||
|
||||
|
||||
def note_header(note: str, db_labels: dict[str, str] | None = None) -> str:
|
||||
"""Return a bilingual vocab section header for a names.json ``note``.
|
||||
|
||||
Prefers the live DB label (:func:`derive_db_labels`), then the static
|
||||
:data:`NOTE_EN` map, and falls back to the JP note alone. Formats matching
|
||||
WolfDawn's own labels, e.g. ``"Weapon · 武器"``.
|
||||
"""
|
||||
english = (db_labels or {}).get(note) or NOTE_EN.get(note)
|
||||
if english and english != note:
|
||||
return f"{english}{_LABEL_SEP}{note}"
|
||||
return note
|
||||
|
|
|
|||
Loading…
Reference in a new issue