diff --git a/gui/wolf_workflow_tab.py b/gui/wolf_workflow_tab.py index bf573cd..084b244 100644 --- a/gui/wolf_workflow_tab.py +++ b/gui/wolf_workflow_tab.py @@ -8,32 +8,26 @@ Mirrors the RPGMaker WorkflowTab, driven by the vendored WolfDawn ``wolf`` CLI Step 1 Pre-process - optional dazedformat + gameupdate/ copy before translating Step 2 Glossary - build vocab.txt (characters / worldbuilding terms) before translating so the AI keeps names and voice consistent - Step 3 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 categories are safe - to translate and leaves the rest as source. Pick Normal/Batch - translation mode here and run Phase 0 (names -> vocab.txt). - Step 4 Translate - run the "Wolf RPG (WolfDawn)" module over files/ in three - ordered phases (RPGMaker's DB-first strategy; mode set in Step 3): - Phase 0 Names - also runnable from Step 3; harvests safe - name translations into vocab.txt + Step 3 Names - translate names.json (Phase 0). WolfDawn safe/refs entries + are translated per-name; verify names are skipped. Harvests + short name terms into vocab.txt. + Step 4 Translate - WolfDawn module over files/ in two phases (after Step 3): Phase 1 Database - item/skill/state descriptions and system messages (DataBase/CDataBase.project) Phase 2 Maps / events - .mps maps, CommonEvent, Game.dat, Evtext; speaker handling and text-wrap settings live under this phase - Step 5 Inject - inject translations + curated names back into the Data/ binaries + Step 5 Inject - inject translations + translated 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 fast in-game / git iteration, or inject everything at once Step 6 Package - run from a loose Data/ folder, or repack Data.wolf Step 7 Saves - fix baked strings in existing .sav files (optional) -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. Step 3 marks the safe subset (by category), -Phase 0 translates only those and harvests them into vocab.txt, and Step 5 injects -the result. +names.json is staged into files/ but is NOT translated in the bulk phases - WolfDawn +tags each name safe / refs / verify and Phase 0 translates only safe+refs entries +(per-name, not per-category), harvests them into vocab.txt, and Step 5 injects the +result. The extracted JSON is staged in ``/wolf_json/`` (a manifest maps each file back to its base binary), then imported into ``files/`` for the shared @@ -110,23 +104,6 @@ PHASE_NAMES_KINDS = {"names"} PHASE_DB_KINDS = {"db"} PHASE_MAPS_EVENTS_KINDS = {"map", "common", "gamedat", "txt", "txt-dir"} -# WolfDawn's inject commands print e.g. "applied 91 translation(s) (0 untranslated, -# 0 drifted); wrote ". 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). -_INJECT_COUNTS_RE = re.compile( - r"applied\s+(\d+)\s+translation.*?(\d+)\s+drifted", re.IGNORECASE | re.DOTALL -) - - -def _parse_inject_counts(stdout: str): - """Return (applied, drifted) ints from wolf inject output, or (None, None).""" - if not stdout: - return None, None - m = _INJECT_COUNTS_RE.search(stdout) - if not m: - return None, None - return int(m.group(1)), int(m.group(2)) - # Glossary-discovery prompt for Copilot / Cursor, tailored to WolfDawn's extracted # JSON (source/text pairs staged in files/). Produces the same two-section format # the shared vocab.txt expects, so the AI output pastes straight into the editor. @@ -151,7 +128,7 @@ _WOLF_GLOSSARY_PROMPT = ( " - Game.dat.json — game/system strings (title, terms).\n" " - .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 (translated separately in Step 3; do NOT list them).\n" "\n" "\n" "\n" @@ -182,8 +159,8 @@ _WOLF_GLOSSARY_PROMPT = ( "# Worldbuilding Terms — rules:\n" "- 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" + "- Exclude: skill names, item names, weapon/armour names (translated via names.json in " + "Step 3). Skip generic RPG words. Do not repeat character names here.\n" "\n" "\n" "\n" @@ -214,62 +191,6 @@ _WOLF_GLOSSARY_PROMPT = ( ) -# Names-safety prompt for a repo-aware AI (Cursor / Copilot with the extracted -# files/ JSON open). WolfDawn groups every value name in names.json under a WOLF -# database category (the 'note' field). Some categories are pure on-screen text, -# others are logic keys (variable / file / event names) that break the game if -# translated. The AI classifies by CATEGORY and returns the list of safe note -# values as a JSON array; the tool then translates only those categories. -_WOLF_NAMES_PROMPT = ( - "You are an expert Japanese WOLF RPG Editor translator and reverse-engineer. Accuracy " - "matters more than coverage: translating the wrong names breaks the game, so be conservative.\n" - "\n" - "\n" - "names.json (a 'kind':'names' object with a 'names' list) is a project-wide list of every " - "\"value name\" WolfDawn found referenced across the WOLF game. Each entry has a Japanese " - "'source', a 'text', an 'occurrences' count, and a 'note' - the WOLF database CATEGORY the " - "name came from (e.g. 武器 = weapons, 技能 = skills, システム変数名 = system variable names, " - "SEリスト = sound-effect list, マップ設定 = map settings).\n" - "Translating everything WILL break the game: many categories are logic keys - the engine " - "compares them by value, looks entries up by exact name, builds file/CG/BGM/SE paths from " - "them, or uses them as variable / switch / event / map names. Only categories that are " - "purely on-screen display text are safe to translate.\n" - "\n" - "\n" - "--- attach the extracted JSON in files/ here (especially names.json) before continuing ---\n" - "\n" - "\n" - "Group the names.json entries by their 'note' value and classify each DISTINCT note " - "category as SAFE or UNSAFE. Return the list of SAFE categories only. Do NOT translate " - "anything yourself and do NOT edit names.json - the tool does the translation, filtered to " - "the categories you approve.\n" - "\n" - "\n" - "\n" - "For each note category, look at its example 'source' values and how they are used in the " - "other files/ JSON (grep the strings across maps / CommonEvent / databases):\n" - "- SAFE -> the category is text shown to the player and nothing depends on its exact value:\n" - " item / weapon / armour / skill names, enemy names, state names, class/job names,\n" - " in-game terms (用語), battle commands, attribute names, actor/party display names.\n" - "- UNSAFE -> the category is (or might be) used as an identifier / key:\n" - " variable / switch / string-variable names, SE / BGM / BGS lists, picture numbers,\n" - " character / face / parallax / window image names, map settings, event names,\n" - " anything looked up or compared by value, or built into a filename or path.\n" - "When a category is ambiguous, or its values look like identifiers (ASCII, digits, " - "underscores, paths, bracket tags like [SE]), classify it UNSAFE. Prefer leaving a category " - "out over risking a break.\n" - "\n" - "\n" - "\n" - "Return ONLY a JSON array of the SAFE note strings, copied EXACTLY as they appear in the " - "'note' fields (same characters, including any brackets or symbols), inside ONE fenced code " - "block and nothing else. Example:\n" - "```json\n" - "[\"武器\", \"防具\", \"技能\", \"アイテム\", \"用語設定\"]\n" - "```\n" - "\n" -) - # Speaker-format prompt for a repo-aware AI. WolfDawn already detects and tags who # speaks on each line, so the only decision left is whether its LOW-confidence # first-line guesses are really speaker names for this game. The AI inspects the @@ -1539,8 +1460,8 @@ class WolfWorkflowTab(QWidget): layout.addLayout(vrow) note = self._desc( - "You don't need to add item / skill / enemy value names here: they're curated in " - "Step 3 and the safe ones are harvested into vocab.txt automatically during Phase 0. " + "You don't need to add item / skill / enemy value names here: Phase 0 translates " + "WolfDawn safe/refs entries from names.json and harvests them into vocab.txt " "Focus this glossary on characters and worldbuilding terms." ) layout.addWidget(note) @@ -1572,33 +1493,19 @@ class WolfWorkflowTab(QWidget): layout.addWidget(self._desc( "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 " - "in-engine text with consistent item / skill / character names (RPGMaker's DB-first strategy)." + "injection can verify each line. Run Step 3 (names) first so vocab.txt is seeded, " + "then Phase 1 (database text) and Phase 2 (maps / events) in order." )) self._add_phase_buttons(layout) 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 3 and harvests them into " - "vocab.txt (grouped by category, e.g. \"Weapon · 武器\"). Curate safe categories and pick " - "the translation mode in Step 3 first, or nothing is translated. Also runnable from Step 3." - )) - 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) - + """Two translation phases (names are handled in Step 3).""" 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." + "CDataBase.project. Run after Step 3 (names) so vocab.txt stays consistent." )) p1 = self._register(_make_btn("▶ Phase 1 · Translate database text", "#00a86b")) p1.clicked.connect( @@ -1610,9 +1517,9 @@ class WolfWorkflowTab(QWidget): layout.addWidget(self._subheading("Phase 2 · Maps / events")) layout.addWidget(self._desc( "Map scripts (.mps), common events (CommonEvent.dat), Game.dat, and Evtext - story " - "dialogue, UI/objective strings, and other event text. Run last so it benefits from " - "the names and terms translated in the earlier phases. Speaker handling and text wrap " - "below apply to the dialogue-like lines in this phase." + "dialogue, UI/objective strings, and other event text. Run after Phase 1 so it " + "benefits from the names and terms already in vocab.txt. Speaker handling and text " + "wrap below apply to the dialogue-like lines in this phase." )) self._add_speaker_options(layout) self._add_wrap_options(layout) @@ -1877,84 +1784,31 @@ class WolfWorkflowTab(QWidget): # ── 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 3 · Translate 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 " - "value, used as variable / file / event names). WolfDawn tags each name with its WOLF " - "database category (a 'note'), so instead of judging thousands of names you choose " - "which categories are safe on-screen text. The translator only touches those; by " - "default none are selected, so nothing changes until you opt categories in." + "names.json is WolfDawn's project-wide name glossary. Each entry has a safety badge: " + "safe (display-only), refs (referenced by name but rewritten on inject), or verify " + "(also in indirect literals - skipped). Phase 0 translates every safe and refs entry " + "individually, regardless of category, and harvests them into vocab.txt." )) - layout.addWidget(self._subheading("1 · Ask a repo-aware AI which categories are safe")) - layout.addWidget(self._desc( - "Copy the prompt into Cursor or Copilot Chat with the extracted files/ JSON open. It " - "groups names by category, checks how each is used, and returns a JSON list of the " - "safe categories in a code block. Conservative by design - ambiguous categories are " - "left out." - )) - prompt_btn = _make_btn("📋 Copy names-safety prompt for Copilot / Cursor", "#5a3a7a") - prompt_btn.clicked.connect(self._copy_wolf_names_prompt) - layout.addWidget(prompt_btn) + self.names_summary_label = QLabel("Open this step after extraction to see name counts.") + self.names_summary_label.setWordWrap(True) + self.names_summary_label.setStyleSheet( + "color:#9cdcfe;font-size:13px;padding:8px 10px;" + "background-color:#1a2430;border:1px solid #2a4a6a;border-radius:4px;" + ) + layout.addWidget(self.names_summary_label) + + refresh_btn = _make_btn("↻ Refresh name counts", "#555") + refresh_btn.clicked.connect(self._refresh_names_summary) + layout.addWidget(refresh_btn) layout.addWidget(_make_hr()) - layout.addWidget(self._subheading("2 · Choose safe categories")) + layout.addWidget(self._subheading("Translate names (Phase 0)")) layout.addWidget(self._desc( - "Paste the AI's JSON list below and click Apply to tick the matching categories, or " - "tick them by hand. Each row shows the category, how many names it has, and an " - "example. Save writes your choice to data/wolf_safe_notes.json." - )) - - self.names_paste = QTextEdit() - self.names_paste.setMaximumHeight(70) - self.names_paste.setFont(QFont("Consolas", 9)) - self.names_paste.setPlaceholderText('Paste the AI list here, e.g. ["武器", "技能", "アイテム"]') - self.names_paste.setStyleSheet( - "QTextEdit{background-color:#252526;color:#d4d4d4;border:1px solid #3c3c3c;" - "border-radius:4px;padding:6px;selection-background-color:#264f78;}" - ) - layout.addWidget(self.names_paste) - - prow = QHBoxLayout() - apply_btn = _make_btn("Apply pasted list", "#007acc") - apply_btn.clicked.connect(self._apply_pasted_notes) - prow.addWidget(apply_btn) - prow.addStretch() - layout.addLayout(prow) - - self.notes_list = QListWidget() - self.notes_list.setMinimumHeight(200) - self.notes_list.setStyleSheet( - "QListWidget{background-color:#252526;border:1px solid #3a3a3a;border-radius:4px;" - "color:#cccccc;font-size:12px;padding:2px;}" - "QListWidget::item{padding:3px 4px;}" - "QListWidget::item:selected{background:#264f78;color:#ffffff;}" - ) - layout.addWidget(self.notes_list, 1) - - nrow = QHBoxLayout() - save_btn = _make_btn("💾 Save safe categories", "#3a7a3a") - save_btn.clicked.connect(self._save_safe_notes) - nrow.addWidget(save_btn) - reload_btn = _make_btn("↻ Refresh", "#555") - reload_btn.clicked.connect(self._reload_notes_list) - nrow.addWidget(reload_btn) - none_btn = _make_btn("Select none", "#3a3a3a") - none_btn.clicked.connect(lambda: self._set_note_checks(False)) - nrow.addWidget(none_btn) - nrow.addStretch() - layout.addLayout(nrow) - self._reload_notes_list() - - layout.addWidget(_make_hr()) - 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) and harvests the translated names into " - "vocab.txt (grouped by category) so the later Translate phases stay consistent. This is " - "the first translation step - pick Normal or Batch below, then run Phase 0 here or from " - "Step 4 (the same mode applies to all phases)." + "Pick Normal or Batch below, then run Phase 0. Only safe and refs entries are sent to " + "the model; verify names stay identical to source so inject skips them." )) self._add_tl_mode_selector(layout) tl_btn = self._register(_make_btn("Translate safe names now (Phase 0)", "#00a86b")) @@ -1962,13 +1816,32 @@ class WolfWorkflowTab(QWidget): lambda: self._navigate_to_translation(kinds=PHASE_NAMES_KINDS, auto_start=True) ) layout.addWidget(tl_btn) + self._refresh_names_summary() - def _copy_wolf_names_prompt(self): + def _load_names_document(self) -> tuple[dict | None, Path | None]: + """Read names.json from files/ or wolf_json/.""" + src = self._names_json_path() + if src is None: + return None, None try: - QApplication.clipboard().setText(_WOLF_NAMES_PROMPT) - self._log("📋 Names-safety prompt copied. Paste it into Cursor/Copilot with files/ open.") + with open(src, "r", encoding="utf-8-sig") as f: + return json.load(f), src except Exception as exc: - self._log(f"❌ Could not copy names prompt: {exc}") + self._log(f"❌ Could not read {NAMES_JSON}: {exc}") + return None, src + + def _refresh_names_summary(self): + if not hasattr(self, "names_summary_label"): + return + data, src = self._load_names_document() + if not isinstance(data, dict): + self.names_summary_label.setText( + "No names.json found - run Step 0 (Extract text) first." + ) + return + summary = wolf_names.format_name_safety_summary(data) + where = src.parent.name if src else WORK_DIR_NAME + self.names_summary_label.setText(f"{summary}\nSource: {where}/{NAMES_JSON}") def _names_json_path(self) -> Path | None: """Locate names.json for reading its note categories (files/ then wolf_json/).""" @@ -1978,124 +1851,14 @@ class WolfWorkflowTab(QWidget): return p return None - def _distinct_notes(self): - """Return [(note, count, sample_source), ...] from names.json, or None.""" - src = self._names_json_path() - if src is None: - return None - try: - with open(src, "r", encoding="utf-8-sig") as f: - data = json.load(f) - except Exception as exc: - self._log(f"❌ Could not read {NAMES_JSON}: {exc}") - return None - counts: dict[str, int] = {} - samples: dict[str, str] = {} - for entry in data.get("names", []): - if not isinstance(entry, dict): - continue - note = str(entry.get("note", "")) - counts[note] = counts.get(note, 0) + 1 - if note not in samples: - samples[note] = str(entry.get("source", "")) - # Most populous categories first - the safe display ones tend to be larger. - ordered = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0])) - return [(note, cnt, samples.get(note, "")) for note, cnt in ordered] - - def _reload_notes_list(self): - if not hasattr(self, "notes_list"): - return - self.notes_list.clear() - notes = self._distinct_notes() - if notes is None: - item = QListWidgetItem("No names.json found — run Step 0 (Extract text) first.") - item.setFlags(Qt.ItemIsEnabled) - self.notes_list.addItem(item) - return - safe = set(wolf_names.load_safe_notes()) - for note, cnt, sample in notes: - sample = sample.replace("\n", " ").replace("\r", " ") - if len(sample) > 40: - sample = sample[:40] + "…" - label = f"{note or '(no category)'} · {cnt} name(s) · e.g. {sample}" - item = QListWidgetItem(label) - item.setData(Qt.UserRole, note) - item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled) - item.setCheckState(Qt.Checked if note in safe else Qt.Unchecked) - self.notes_list.addItem(item) - - def _set_note_checks(self, checked: bool): - if not hasattr(self, "notes_list"): - return - state = Qt.Checked if checked else Qt.Unchecked - for i in range(self.notes_list.count()): - it = self.notes_list.item(i) - if it.flags() & Qt.ItemIsUserCheckable: - it.setCheckState(state) - - def _apply_pasted_notes(self): - text = self.names_paste.toPlainText().strip() - if text.startswith("```"): - lines = text.splitlines() - if lines and lines[0].startswith("```"): - lines = lines[1:] - if lines and lines[-1].strip().startswith("```"): - lines = lines[:-1] - text = "\n".join(lines).strip() - try: - parsed = json.loads(text) - if not isinstance(parsed, list): - raise ValueError("expected a JSON array of note strings") - wanted = {str(n) for n in parsed} - except Exception as exc: - QMessageBox.warning( - self, "Invalid list", - f"Paste the AI's JSON array of category names.\n{exc}", - ) - return - matched = 0 - unmatched = set(wanted) - for i in range(self.notes_list.count()): - it = self.notes_list.item(i) - if not (it.flags() & Qt.ItemIsUserCheckable): - continue - note = it.data(Qt.UserRole) - if note in wanted: - it.setCheckState(Qt.Checked) - matched += 1 - unmatched.discard(note) - else: - it.setCheckState(Qt.Unchecked) - msg = f"Ticked {matched} categor(y/ies) from the pasted list." - if unmatched: - msg += f" {len(unmatched)} not found in this game: {', '.join(sorted(unmatched))}" - self._log(msg) - - def _save_safe_notes(self): - if not hasattr(self, "notes_list"): - return - safe = [] - for i in range(self.notes_list.count()): - it = self.notes_list.item(i) - if (it.flags() & Qt.ItemIsUserCheckable) and it.checkState() == Qt.Checked: - safe.append(it.data(Qt.UserRole)) - try: - wolf_names.save_safe_notes(safe) - self._log( - f"✅ Saved {len(safe)} safe categor(y/ies) to data/wolf_safe_notes.json. " - "Translate the safe names below, then inject in Step 5." - ) - except Exception as exc: - self._log(f"❌ Could not save safe categories: {exc}") - # ── Step 4: Inject ───────────────────────────────────────────────────────── def _build_step4_inject(self, layout: QVBoxLayout): layout.addWidget(_make_section_label("Step 5 · 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 " - "entries you translated change). Lines whose inline codes changed are skipped and " + "byte-exact. Translated safe/refs name values from names.json are applied across Data/ " + "(only the entries you translated change). Lines whose inline codes changed are skipped and " "reported (unless you allow drift). Full inject and any inject that includes " f"{NAMES_JSON} reset live Data/ from {WORK_DIR_NAME}/originals/ first." )) @@ -2126,8 +1889,9 @@ 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 " - "apply the safe name values across Data/." + f"{NAMES_JSON} appears here once Phase 0 has translated it (checked by default). " + "Name injection resets live Data/ from wolf_json/originals/ first, then applies " + "every safe/refs name across all binaries." )) self.inject_list = QListWidget() @@ -2160,7 +1924,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 translated name values from names.json " "across Data/. Use this once translation is complete before packaging in Step 6." )) inject_btn = self._register(_make_btn("Inject all translations", "#00a86b")) @@ -2180,9 +1944,9 @@ class WolfWorkflowTab(QWidget): self._current_step_index = idx if previous == 0 and idx != 0: self._auto_import_if_needed() - # Index 3 == Names: refresh the note-category checklist from names.json. + # Index 3 == Names: refresh the per-name safety summary. if idx == 3: - self._reload_notes_list() + self._refresh_names_summary() # Index 5 == Inject: keep the quick-inject picker in sync with translated/. elif idx == 5: self._refresh_inject_list() @@ -2207,7 +1971,8 @@ class WolfWorkflowTab(QWidget): item = QListWidgetItem(json_name) item.setData(Qt.UserRole, json_name) item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled) - item.setCheckState(Qt.Unchecked) + default_checked = json_name == NAMES_JSON + item.setCheckState(Qt.Checked if default_checked else Qt.Unchecked) self.inject_list.addItem(item) listed += 1 if listed == 0: @@ -2269,12 +2034,12 @@ class WolfWorkflowTab(QWidget): """Inject translations into the game's Data/ binaries. only_json: - None — full inject: every document + curated name values across Data/. - set(names)— quick inject: only those JSON files; the curated name values + None — full inject: every document + translated name values across Data/. + set(names)— quick inject: only those JSON files; name values from names.json 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. + Name values are applied only when translated/names.json exists (i.e. Phase 0 + has run); otherwise names are left untouched. """ if not self._require_manifest(): return @@ -2328,6 +2093,8 @@ class WolfWorkflowTab(QWidget): log(f"Resetting live Data/ from {WORK_DIR_NAME}/originals/ …") self._restore_live_from_originals(entries, data_dir_path, log) + names_restored = only_json is None or will_names + if only_json is None: strings_targets = { e["json"] for e in entries if e.get("kind") != "names" @@ -2366,10 +2133,18 @@ class WolfWorkflowTab(QWidget): str(src), base, out, allow_code_drift=allow_drift, en_punct=en_punct, log_fn=log, ) - a, d = _parse_inject_counts(res.stdout) - if res.ok and not (a == 0 and (d or 0) > 0): + a, d = wolfdawn.parse_strings_inject_counts(res.stdout) + strings_ok = wolfdawn.inject_had_applied(a) or ( + res.ok and not (a == 0 and (d or 0) > 0) + ) + if strings_ok: applied += 1 _sync_json(entry["json"], src, log) + if not res.ok and wolfdawn.inject_had_applied(a): + log( + f" ⚠ {entry['json']}: wolf exited {res.returncode} " + f"after applying {a} change(s) — see warnings above" + ) elif res.ok: # Exit 0 but nothing applied and lines drifted = stale/injected base. failed += 1 @@ -2382,21 +2157,43 @@ class WolfWorkflowTab(QWidget): failed += 1 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 - # do this if the user explicitly ticked the names file, since it rewrites - # many binaries and would add noise to the git diff otherwise. + # Name values: apply across the whole data dir when translated/names.json + # exists. For a quick inject, only include NAMES_JSON when you want names + # applied (it rewrites many binaries). if will_names and names_entry and names_src is not None: - log("Applying curated name values across Data/ …") + if not names_restored: + log( + f"Resetting live Data/ from {WORK_DIR_NAME}/originals/ " + "before name injection …" + ) + self._restore_live_from_originals(entries, data_dir_path, log) + log("Applying translated name values across Data/ …") res = wolfdawn.names_inject( str(names_src), data_dir, allow_code_drift=allow_drift, en_punct=en_punct, log_fn=log, ) - a, d = _parse_inject_counts(res.stdout) - if res.ok and not (a == 0 and (d or 0) > 0): + out = (res.stdout or "") + (res.stderr or "") + a, d = wolfdawn.parse_names_inject_counts(out) + # Keep wolf_json/names.json aligned with translated/ even when the + # binary pass is a no-op (stale base) or exits 2 after partial guards. + _sync_json(names_entry["json"], names_src, log) + if wolfdawn.inject_had_applied(a): applied += 1 - _sync_json(names_entry["json"], names_src, log) - elif res.ok: + if not res.ok: + log( + f" ⚠ names: wolf exited {res.returncode} after applying " + f"{a} name change(s) — see warnings above" + ) + elif res.ok and a == 0: + failed += 1 + log( + f" ⚠ names: 0 applied — the live Data/ binaries no longer contain " + "the Japanese 'source' strings in names.json (often because a prior " + "inject already translated them without resetting from originals). " + "Use “Inject all translations”, or tick names.json in quick inject " + f"(which resets from {WORK_DIR_NAME}/originals/ first)." + ) + elif res.ok and (d or 0) > 0: failed += 1 log( f" ⚠ names: 0 applied, {d} skipped as drift — sources in " diff --git a/modules/wolfdawn.py b/modules/wolfdawn.py index dac3279..5b64509 100644 --- a/modules/wolfdawn.py +++ b/modules/wolfdawn.py @@ -17,16 +17,13 @@ Only entries whose ``source`` contains target-language (Japanese by default) text are sent to the model; everything else keeps ``text == source`` so inject is a no-op for it. -names.json safety: WolfDawn's ``names-extract`` groups every value name under a -``note`` category, and many categories are logic keys (variable / file / event -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. 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. +names.json safety: WolfDawn tags every value name with a static ``safety`` badge +(``safe``, ``refs``, or ``verify``). For ``kind == "names"`` documents, only +entries whose badge is ``safe`` or ``refs`` are translated; ``verify`` names and +legacy entries without a badge keep ``text == source``. After translating +names.json, translatable entries are harvested into ``vocab.txt`` (grouped by +``note``, with bilingual headers) so later phases keep item / skill / term names +consistent. 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. @@ -90,12 +87,7 @@ LANGREGEX = r"[一-龠ぁ-ゔァ-ヴーa-zA-Z0-9\uFF61-\uFF9F]+" # configurable from the workflow (data/wolf_speakers.json). SPEAKER_CONFIG = wolf_speakers.load_config() -# names.json safety: WolfDawn tags every value name with a ``note`` category. Many -# categories are logic keys (variable/file/event names) that break the game if -# translated, so only entries whose ``note`` is in this opt-in list are sent to -# the model; the rest keep text == source. Empty by default = translate nothing. -# Configured from the workflow (data/wolf_safe_notes.json). -SAFE_NOTES = wolf_names.load_safe_notes() +# names.json: translate per-entry safe/refs badges only (see util.wolf_names). # Text wrapping: rewrap translated dialogue to a character width the same way the # RPGMaker module does (util.dazedwrap), so English lines fit WOLF's message box. @@ -135,12 +127,11 @@ 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, VOCAB + global ESTIMATE, TOKENS, FILENAME, 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. @@ -251,13 +242,13 @@ def parseDocument(data, filename): src = e.get("source") if not (isinstance(src, str) and re.search(LANGREGEX, src)): return False - # names.json: only translate categories the user marked safe (by note). - if is_names and not wolf_names.is_note_safe(e.get("note", ""), SAFE_NOTES): + # names.json: only translate WolfDawn safe/refs entries (per-name badge). + if is_names and not wolf_names.is_name_translatable(e): return False return True # Only translate entries that actually contain target-language text (and, for - # names.json, sit in a safe note category); the rest keep text == source so + # names.json, carry a translatable safety badge); the rest keep text == source so # WolfDawn treats them as untouched on inject. translatable = [e for e in entries if _translatable(e)] @@ -321,26 +312,33 @@ def parseDocument(data, filename): def _harvest_names_to_vocab(data): - """Write translated safe names.json entries into vocab.txt, grouped by note. + """Write short translated name labels 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`. + Profile blurbs and other content-shaped names are translated in names.json but + skipped here. Categories with no harvestable terms remove any stale section. """ try: db_labels = wolf_names.derive_db_labels("files") by_note: dict[str, list] = {} + touched_notes: set[str] = set() for entry in data.get("names") or []: - note = entry.get("note", "") - if not wolf_names.is_note_safe(note, SAFE_NOTES): + if not wolf_names.is_name_translatable(entry): + continue + note = str(entry.get("note", "")) + touched_notes.add(note) + if not wolf_names.is_vocab_harvest_candidate(entry): 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) + for note in touched_notes: + header = wolf_names.note_header(note, db_labels) + pairs = by_note.get(note, []) + if pairs: + wolf_vocab.update_vocab_section(header, pairs) + else: + wolf_vocab.remove_vocab_section(header) except Exception: traceback.print_exc() diff --git a/tests/test_wolf_inject_counts.py b/tests/test_wolf_inject_counts.py new file mode 100644 index 0000000..1678dff --- /dev/null +++ b/tests/test_wolf_inject_counts.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""Unit tests for WolfDawn inject stdout parsers.""" + +from __future__ import annotations + +import os +import sys +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +os.chdir(ROOT) +sys.path.insert(0, str(ROOT)) + +from util import wolfdawn # noqa: E402 + + +class WolfInjectCountsTests(unittest.TestCase): + def test_parse_names_inject_counts(self): + out = ( + "applied 2336 name change(s) (0 drifted/unmatched); " + "wrote 78 file(s) in place\n" + "WARNING: 2 line(s) left UNTRANSLATED by a safety guard\n" + ) + applied, drifted = wolfdawn.parse_names_inject_counts(out) + self.assertEqual(applied, 2336) + self.assertEqual(drifted, 0) + self.assertTrue(wolfdawn.inject_had_applied(applied)) + + def test_parse_strings_inject_counts(self): + out = "applied 91 translation(s) (3 drifted); wrote Map001.mps\n" + applied, drifted = wolfdawn.parse_strings_inject_counts(out) + self.assertEqual(applied, 91) + self.assertEqual(drifted, 3) + + def test_inject_had_applied_rejects_zero(self): + self.assertFalse(wolfdawn.inject_had_applied(0)) + self.assertFalse(wolfdawn.inject_had_applied(None)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_wolf_names.py b/tests/test_wolf_names.py index 2bac9ce..9e60096 100644 --- a/tests/test_wolf_names.py +++ b/tests/test_wolf_names.py @@ -1,8 +1,9 @@ #!/usr/bin/env python3 -"""Unit tests for the WOLF names safe-note config in util/wolf_names.py.""" +"""Unit tests for WolfDawn names.json helpers in util/wolf_names.py.""" from __future__ import annotations +import json import os import sys import tempfile @@ -16,41 +17,62 @@ sys.path.insert(0, str(ROOT)) from util import wolf_names as wn # noqa: E402 -class TestSafeNotesIO(unittest.TestCase): - def setUp(self): - self._orig = wn.SAFE_NOTES_PATH - self._tmp = tempfile.TemporaryDirectory() - wn.SAFE_NOTES_PATH = Path(self._tmp.name) / "wolf_safe_notes.json" +class TestNameTranslatable(unittest.TestCase): + def test_safe_and_refs_are_translatable(self): + self.assertTrue(wn.is_name_translatable({"safety": "safe"})) + self.assertTrue(wn.is_name_translatable({"safety": "refs"})) - def tearDown(self): - wn.SAFE_NOTES_PATH = self._orig - self._tmp.cleanup() - - def test_default_is_empty(self): - self.assertEqual(wn.load_safe_notes(), []) - - def test_round_trip_preserves_order_and_unicode(self): - wn.save_safe_notes(["武器", "技能", "アイテム"]) - self.assertEqual(wn.load_safe_notes(), ["武器", "技能", "アイテム"]) - - def test_save_dedupes(self): - wn.save_safe_notes(["武器", "武器", "技能"]) - self.assertEqual(wn.load_safe_notes(), ["武器", "技能"]) - - def test_ignores_non_list_payload(self): - wn.SAFE_NOTES_PATH.write_text('{"武器": true}', encoding="utf-8") - self.assertEqual(wn.load_safe_notes(), []) + def test_verify_and_missing_are_not_translatable(self): + self.assertFalse(wn.is_name_translatable({"safety": "verify"})) + self.assertFalse(wn.is_name_translatable({})) + self.assertFalse(wn.is_name_translatable({"safety": ""})) -class TestIsNoteSafe(unittest.TestCase): - def test_membership(self): - safe = ["武器", "技能"] - self.assertTrue(wn.is_note_safe("武器", safe)) - self.assertFalse(wn.is_note_safe("通常変数名", safe)) +class TestVocabHarvestCandidate(unittest.TestCase): + def test_short_weapon_name_harvests(self): + entry = {"source": "ダガー", "note": "武器", "safety": "safe"} + self.assertTrue(wn.is_vocab_harvest_candidate(entry)) - def test_empty_list_is_never_safe(self): - self.assertFalse(wn.is_note_safe("武器", [])) - self.assertFalse(wn.is_note_safe("", [])) + def test_multiline_profile_skipped(self): + entry = { + "source": "セルリアと申します。\nよろしくお願いいたします。", + "note": "├■プロフィール", + "safety": "safe", + } + self.assertFalse(wn.is_vocab_harvest_candidate(entry)) + + def test_profile_note_skipped_even_when_single_line(self): + entry = {"source": "ローザだ。", "note": "├■プロフィール", "safety": "safe"} + self.assertFalse(wn.is_vocab_harvest_candidate(entry)) + + def test_resistance_label_still_harvests(self): + entry = {"source": "物理50%軽減", "note": "┣ 属性耐性", "safety": "safe"} + self.assertTrue(wn.is_vocab_harvest_candidate(entry)) + + +class TestCountNameSafety(unittest.TestCase): + DOC = { + "kind": "names", + "names": [ + {"source": "a", "safety": "safe"}, + {"source": "b", "safety": "refs"}, + {"source": "c", "safety": "verify"}, + {"source": "d"}, + ], + } + + def test_counts_badges(self): + counts = wn.count_name_safety(self.DOC) + self.assertEqual(counts["safe"], 1) + self.assertEqual(counts["refs"], 1) + self.assertEqual(counts["verify"], 1) + self.assertEqual(counts["unknown"], 1) + self.assertEqual(counts["translatable"], 2) + + def test_summary_mentions_translatable_count(self): + summary = wn.format_name_safety_summary(self.DOC) + self.assertIn("2 of 4", summary) + self.assertIn("verify", summary) class TestNoteHeader(unittest.TestCase): @@ -74,15 +96,12 @@ class TestDeriveDbLabels(unittest.TestCase): 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", diff --git a/tests/test_wolfdawn.py b/tests/test_wolfdawn.py index ab2a57d..8d3e16a 100644 --- a/tests/test_wolfdawn.py +++ b/tests/test_wolfdawn.py @@ -38,11 +38,10 @@ 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 = [] + self.vocab_remove_writes = [] - def run(self, data, filename="doc.json", estimate=False, safe_notes=None): + def run(self, data, filename="doc.json", estimate=False): def translate(text, history, history_ctx=None): self.captured.append(copy.deepcopy(text)) return _mock_translate(text, history, history_ctx) @@ -53,7 +52,6 @@ class _WolfTranslateHarness: 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 @@ -62,8 +60,6 @@ class _WolfTranslateHarness: # 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: data_copy = copy.deepcopy(data) result = wd.parseDocument(data_copy, filename) @@ -72,7 +68,6 @@ class _WolfTranslateHarness: wd.translateAI = orig_t 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 @@ -110,10 +105,25 @@ GAMEDAT_DOC = { NAMES_DOC = { "kind": "names", - "count": 2, + "count": 3, "names": [ - {"source": "剣", "text": "剣", "occurrences": 2, "note": "武器"}, - {"source": "スイッチ状態", "text": "スイッチ状態", "occurrences": 1, "note": "通常変数名"}, + {"source": "剣", "text": "剣", "occurrences": 2, "note": "武器", "safety": "safe"}, + {"source": "槍", "text": "槍", "occurrences": 1, "note": "武器", "safety": "refs"}, + { + "source": "スイッチ状態", + "text": "スイッチ状態", + "occurrences": 1, + "note": "通常変数名", + "safety": "verify", + }, + ], +} + +LEGACY_NAMES_DOC = { + "kind": "names", + "count": 1, + "names": [ + {"source": "剣", "text": "剣", "occurrences": 1, "note": "武器"}, ], } @@ -159,42 +169,69 @@ class TestTranslationWriteback(unittest.TestCase): self.assertIsNone(err) self.assertEqual(data["lines"][0]["text"], "EN_ゲームタイトル") - def test_names_translate_only_safe_notes(self): - # Only the entry whose note is opted-in gets translated. - (data, _t, err), _c = _WolfTranslateHarness().run( - NAMES_DOC, "names.json", safe_notes=["武器"] + def test_names_translate_only_safe_and_refs(self): + (data, _t, err), captured = _WolfTranslateHarness().run( + NAMES_DOC, "names.json" ) self.assertIsNone(err) self.assertEqual(data["names"][0]["text"], "EN_剣") - self.assertEqual(data["names"][0]["source"], "剣") - # The unsafe category (variable name) is left identical to the source. - self.assertEqual(data["names"][1]["text"], "スイッチ状態") - self.assertEqual(data["names"][1]["source"], "スイッチ状態") + self.assertEqual(data["names"][1]["text"], "EN_槍") + self.assertEqual(data["names"][2]["text"], "スイッチ状態") + self.assertEqual(captured, [["剣", "槍"]]) - def test_names_default_translates_nothing(self): - # Empty safe list = safe default: no name is translated. + def test_names_without_safety_badges_translate_nothing(self): (data, _t, err), captured = _WolfTranslateHarness().run( - NAMES_DOC, "names.json", safe_notes=[] + LEGACY_NAMES_DOC, "names.json" ) self.assertIsNone(err) self.assertEqual(captured, []) - for entry in data["names"]: - self.assertEqual(entry["text"], entry["source"]) + self.assertEqual(data["names"][0]["text"], "剣") 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=["武器"]) + (_data, _t, err), _c = harness.run(NAMES_DOC, "names.json") self.assertIsNone(err) - self.assertEqual(harness.vocab_writes, [("Weapon · 武器", [("剣", "EN_剣")])]) + self.assertEqual( + harness.vocab_writes, + [ + ("Weapon · 武器", [("剣", "EN_剣"), ("槍", "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=["武器"]) + harness.run(NAMES_DOC, "names.json", estimate=True) self.assertEqual(harness.vocab_writes, []) + def test_names_harvest_skips_profile_blurbs(self): + doc = { + "kind": "names", + "names": [ + {"source": "ダガー", "text": "EN_ダガー", "note": "武器", "safety": "safe"}, + { + "source": "セルリアと申します。\nよろしくお願いいたします。", + "text": "EN_profile", + "note": "├■プロフィール", + "safety": "safe", + }, + ], + } + harness = _WolfTranslateHarness() + harness.vocab_remove_writes = [] + orig_remove = wd.wolf_vocab.remove_vocab_section + + def capture_remove(category): + harness.vocab_remove_writes.append(category) + + wd.wolf_vocab.remove_vocab_section = capture_remove + try: + (_data, _t, err), _c = harness.run(doc, "names.json") + finally: + wd.wolf_vocab.remove_vocab_section = orig_remove + self.assertIsNone(err) + self.assertEqual(harness.vocab_writes, [("Weapon · 武器", [("ダガー", "EN_ダガー")])]) + self.assertEqual(harness.vocab_remove_writes, ["├■プロフィール"]) + def test_txtdir_translates(self): (data, _t, err), _c = _WolfTranslateHarness().run(TXTDIR_DOC, "Evtext.json") self.assertIsNone(err) diff --git a/util/vocab.py b/util/vocab.py index 6144dd0..0d87bd2 100644 --- a/util/vocab.py +++ b/util/vocab.py @@ -127,3 +127,39 @@ def update_vocab_section(category: str, pairs) -> None: ) tmp_path.write_text(combined, encoding="utf-8") os.replace(tmp_path, VOCAB_PATH) + + +def remove_vocab_section(category: str) -> None: + """Remove a ``# {category}`` section from the game-specific vocab, if present.""" + with _VOCAB_LOCK: + if not VOCAB_PATH.is_file(): + return + existing = VOCAB_PATH.read_text(encoding="utf-8") + + idx = existing.find(BASE_SEPARATOR) + if idx != -1: + game_part = existing[:idx] + base_part = existing[idx:] + else: + game_part = existing + base_part = "" + + pattern = re.compile( + rf"^[\t ]*#+\s*{re.escape(category)}\s*$\r?\n.*?(?=^[\t ]*#|\Z)", + re.MULTILINE | re.DOTALL, + ) + new_game = pattern.sub("", game_part, count=1) + if new_game == game_part: + return + new_game = re.sub(r"\n{3,}", "\n\n", new_game).rstrip("\n") + if base_part: + combined = new_game + "\n\n" + base_part if new_game else base_part + else: + combined = new_game + "\n" if new_game else "" + 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) diff --git a/util/wolf_names.py b/util/wolf_names.py index c2e61a7..09e1152 100644 --- a/util/wolf_names.py +++ b/util/wolf_names.py @@ -1,38 +1,46 @@ -"""Safe-to-translate ``note`` categories for the WOLF (WolfDawn) name glossary. +"""WolfDawn ``names.json`` helpers for the WOLF translation workflow. -WolfDawn's ``names-extract`` produces ``names.json``: a project-wide list of every -"value name" the game references (item / skill / enemy names, but also variable -names, file/SE/BGM keys, event names, map settings, ...). Each entry carries a -``note`` field naming the WOLF database category it came from, e.g. ``武器`` -(weapons), ``技能`` (skills), ``システム変数名`` (system variable names). +WolfDawn's ``names-extract`` produces ``names.json``: a project-wide glossary of +every value name the game references. Each entry has a ``source`` / ``text`` pair, +a ``note`` field (the WOLF database category it came from), and a static +``safety`` badge from WolfDawn's command-stream analysis: -Translating *all* of them corrupts the game, because many categories are logic -keys (compared by value, used to build filenames, stored in variables). Only the -categories that are pure on-screen text are safe to translate. +* ``safe`` - display-only; no command references the string by name. +* ``refs`` - referenced by name, but WolfDawn rewrites every literal on inject. +* ``verify`` - also appears in indirect literals; left untranslated by default. -Rather than hand-editing 2000+ entries, the workflow classifies by ``note``: a -repo-aware AI decides which *categories* are display-only, and that short list is -saved here. The WolfDawn translation module (:mod:`modules.wolfdawn`) then only -translates ``names.json`` entries whose ``note`` is in the safe list, leaving -everything else identical to the source (so injection is a no-op for it). +:mod:`modules.wolfdawn` translates only entries whose badge is ``safe`` or +``refs``. ``verify`` and legacy entries without a badge keep ``text == source`` +so injection is a no-op for them. -The default is an empty list: nothing is translated until the user opts a -category in, which keeps the safe-by-default behaviour the workflow relies on. +Phase 0 still harvests translated **short name-like** entries into ``vocab.txt``, +grouped by ``note``. Multi-line profile blurbs, dialogue, and other content-shaped +names are translated in ``names.json`` but omitted from the glossary. """ from __future__ import annotations import json +import re +from typing import Any -from util.paths import DATA_DIR +SAFETY_SAFE = "safe" +SAFETY_REFS = "refs" +SAFETY_VERIFY = "verify" +_TRANSLATABLE_SAFETY = frozenset({SAFETY_SAFE, SAFETY_REFS}) -SAFE_NOTES_PATH = DATA_DIR / "wolf_safe_notes.json" +# names.json ``note`` values that are content fields, not short display names. +_VOCAB_SKIP_NOTE_RE = re.compile( + r"プロフィール|説明|セリフ|台詞|会話|自己紹介|挨拶|モノローグ|独白", +) + +# Short row labels and item/skill names fit; multi-line blurbs do not. +_VOCAB_HARVEST_MAX_LEN = 72 # 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. +# half only, so this static map provides an English label for the common categories +# when the live DB labels are unavailable. NOTE_EN: dict[str, str] = { "武器": "Weapon", "防具": "Armor", @@ -48,53 +56,107 @@ NOTE_EN: dict[str, str] = { "敵キャラ個体データ": "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.""" - try: - if SAFE_NOTES_PATH.is_file(): - data = json.loads(SAFE_NOTES_PATH.read_text(encoding="utf-8")) - if isinstance(data, list): - return [str(n) for n in data] - except Exception: - pass - return [] +def parse_names_entries(data: dict[str, Any]) -> list[dict[str, Any]]: + names = data.get("names", []) + if not isinstance(names, list): + return [] + return [entry for entry in names if isinstance(entry, dict)] -def save_safe_notes(notes: list[str]) -> None: - """Persist the safe ``note`` categories (de-duplicated, order preserved).""" - seen: set[str] = set() - ordered: list[str] = [] - for note in notes: - note = str(note) - if note not in seen: - seen.add(note) - ordered.append(note) - DATA_DIR.mkdir(parents=True, exist_ok=True) - SAFE_NOTES_PATH.write_text( - json.dumps(ordered, ensure_ascii=False, indent=4), encoding="utf-8" +def has_safety_metadata(data: dict[str, Any]) -> bool: + """True when *data* includes WolfDawn per-name ``safety`` badges.""" + return any("safety" in entry for entry in parse_names_entries(data)) + + +def entry_safety(entry: dict[str, Any]) -> str: + """Return the normalised ``safety`` badge for one names.json entry.""" + return str(entry.get("safety", "")).strip().lower() + + +def is_name_translatable(entry: dict[str, Any]) -> bool: + """True when WolfDawn marks *entry* as safe or refs (per-name, not per-category).""" + return entry_safety(entry) in _TRANSLATABLE_SAFETY + + +def is_vocab_harvest_candidate(entry: dict[str, Any]) -> bool: + """True when a translated names.json entry should seed ``vocab.txt``. + + Translation uses :func:`is_name_translatable`; this is stricter. Profile text, + multi-line blurbs, and dialogue-shaped strings are still translated in + ``names.json`` but are not glossary terms. + """ + if not is_name_translatable(entry): + return False + src = str(entry.get("source", "")) + if not src.strip(): + return False + if "\n" in src or "\r" in src: + return False + if len(src) > _VOCAB_HARVEST_MAX_LEN: + return False + note = str(entry.get("note", "")) + if _VOCAB_SKIP_NOTE_RE.search(note): + return False + # Two or more sentence endings usually means dialogue, not a row label. + if len(re.findall(r"[。!?]", src)) >= 2: + return False + return True + + +def count_name_safety(data: dict[str, Any]) -> dict[str, int]: + """Return per-badge counts for a names.json document.""" + counts = { + "total": 0, + "safe": 0, + "refs": 0, + "verify": 0, + "unknown": 0, + "translatable": 0, + } + for entry in parse_names_entries(data): + counts["total"] += 1 + safety = entry_safety(entry) + if safety == SAFETY_SAFE: + counts["safe"] += 1 + elif safety == SAFETY_REFS: + counts["refs"] += 1 + elif safety == SAFETY_VERIFY: + counts["verify"] += 1 + else: + counts["unknown"] += 1 + counts["translatable"] = counts["safe"] + counts["refs"] + return counts + + +def format_name_safety_summary(data: dict[str, Any]) -> str: + """One-line summary for the workflow UI.""" + counts = count_name_safety(data) + if not counts["total"]: + return "No names found in names.json." + if not has_safety_metadata(data): + return ( + f"{counts['total']} name(s), but no WolfDawn safety badges - re-run names-extract " + "with a current WolfDawn build before Phase 0." + ) + parts = [] + if counts["safe"]: + parts.append(f"{counts['safe']} safe") + if counts["refs"]: + parts.append(f"{counts['refs']} refs") + if counts["verify"]: + parts.append(f"{counts['verify']} verify") + badge_text = ", ".join(parts) if parts else "no badges" + return ( + f"{counts['translatable']} of {counts['total']} name(s) will translate " + f"({badge_text}). Verify names are skipped." ) -def is_note_safe(note: str, safe_notes) -> bool: - """True if *note* is in the *safe_notes* collection.""" - 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. - """ + """Build a ``{japanese_note: english}`` map from staged WolfDawn ``db`` files.""" from pathlib import Path labels: dict[str, str] = {} @@ -104,12 +166,12 @@ def derive_db_labels(files_dir) -> dict[str, str]: return labels for path in sorted(base.glob("*.project.json")): try: - data = json.loads(path.read_text(encoding="utf-8-sig")) + doc = json.loads(path.read_text(encoding="utf-8-sig")) except Exception: continue - if data.get("kind") != "db": + if doc.get("kind") != "db": continue - for group in data.get("groups") or []: + for group in doc.get("groups") or []: type_name = group.get("typeName") or "" if _LABEL_SEP in type_name: english, _, japanese = type_name.partition(_LABEL_SEP) @@ -122,12 +184,7 @@ def derive_db_labels(files_dir) -> dict[str, str]: 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 · 武器"``. - """ + """Return a bilingual vocab section header for a names.json ``note``.""" english = (db_labels or {}).get(note) or NOTE_EN.get(note) if english and english != note: return f"{english}{_LABEL_SEP}{note}" diff --git a/util/wolfdawn/__init__.py b/util/wolfdawn/__init__.py index edd5444..7b63697 100644 --- a/util/wolfdawn/__init__.py +++ b/util/wolfdawn/__init__.py @@ -42,11 +42,51 @@ __all__ = [ "names_extract", "strings_inject", "names_inject", + "parse_strings_inject_counts", + "parse_names_inject_counts", + "inject_had_applied", "pack", "save_update", "names_check", ] +# strings-inject: "applied N translation(s) (M drifted)"; names-inject uses "name change(s)". +_INJECT_COUNTS_RE = re.compile( + r"applied\s+(\d+)\s+translation.*?(\d+)\s+drifted", re.IGNORECASE | re.DOTALL +) +_NAMES_INJECT_COUNTS_RE = re.compile( + r"applied\s+(\d+)\s+name change.*?(\d+)\s+drifted", re.IGNORECASE | re.DOTALL +) + + +def parse_strings_inject_counts(stdout: str) -> tuple[int | None, int | None]: + """Return (applied, drifted) from wolf strings-inject output, or (None, None).""" + if not stdout: + return None, None + m = _INJECT_COUNTS_RE.search(stdout) + if not m: + return None, None + return int(m.group(1)), int(m.group(2)) + + +def parse_names_inject_counts(stdout: str) -> tuple[int | None, int | None]: + """Return (applied, drifted) from wolf names-inject output, or (None, None).""" + if not stdout: + return None, None + m = _NAMES_INJECT_COUNTS_RE.search(stdout) + if not m: + return None, None + return int(m.group(1)), int(m.group(2)) + + +def inject_had_applied(applied: int | None) -> bool: + """True when WolfDawn reported at least one applied change. + + WolfDawn may exit 2 when a few lines fail safety guards but still writes the + rest; treat a positive applied count as success for inject bookkeeping. + """ + return applied is not None and applied > 0 + # Committed, prebuilt binaries live here (per-platform) so end users don't need a # Rust toolchain. When one is missing we download it from the WolfDawn release. _PACKAGE_DIR = Path(__file__).resolve().parent