Compare commits
14 commits
1393afb8e4
...
ef055cbb33
| Author | SHA1 | Date | |
|---|---|---|---|
| ef055cbb33 | |||
| a2411b3e31 | |||
| dd048f48f1 | |||
| 7079efc639 | |||
| 2b4a0e257f | |||
| 0a79b74703 | |||
| 3de5368817 | |||
| 6badfcb246 | |||
| 42cda41458 | |||
| 429ad43918 | |||
| ee21f1f862 | |||
| 979ed019d3 | |||
| 4c2c7e3c4a | |||
| 119981dee1 |
25 changed files with 5733 additions and 991 deletions
12
.env.example
12
.env.example
|
|
@ -65,15 +65,9 @@ listWidth="100"
|
|||
#The wordwrap of items and help text
|
||||
noteWidth="75"
|
||||
|
||||
# WolfDawn names.json (Step 3): re-wrap selected note categories with dazedwrap
|
||||
#wolfNameWrap="false"
|
||||
#wolfNameWrapWidth="60"
|
||||
#wolfNameWrapNotes='["├■プロフィール","説明"]'
|
||||
|
||||
# WolfDawn dialogue wrap at translate time is optional / advanced; normal workflow
|
||||
# leaves line breaks alone; optional `wolf relayout` after inject (Step 5, off by default).
|
||||
#wolfWrap="false"
|
||||
#wolfWidth="55"
|
||||
# WolfDawn database (Step 4): limit which DB sheets are translated (JSON array of group keys)
|
||||
#wolfDbIncludeGroups='["DataBase.project.json|Skill · 技能"]'
|
||||
#wolfDbIncludeTiers='["foundation","system"]'
|
||||
|
||||
#CSV delimiter character (comma, semicolon, or tab)
|
||||
csvDelimiter=","
|
||||
|
|
|
|||
26
README.md
26
README.md
|
|
@ -324,14 +324,26 @@ Open the **Workflow** tab and choose **Wolf RPG (WolfDawn)** from the engine sel
|
|||
|
||||
| Step | Action |
|
||||
|------|--------|
|
||||
| **0 Project** | Select the game root folder (browse or Enter — detection also runs when you reopen the tab). If `wolf_json/` does not exist yet, the tool automatically unpacks `.wolf` archives when needed and extracts text into the game's `wolf_json/` folder (maps, common events, databases, `Game.dat`, Evtext, and `names.json`). A checklist lists every JSON file in `wolf_json/`; tick the ones you want and click **Import** (or leave Step 0 — checked files auto-import into the tool's `files/` folder, matching the RPG Maker workflow). Extraction snapshots pristine binaries into `wolf_json/originals/` for idempotent inject in Step 5. |
|
||||
| **0 Project** | Select the game root folder (browse or Enter — detection also runs when you reopen the tab). If `wolf_json/` does not exist yet, the tool automatically unpacks `.wolf` archives when needed and extracts text into the game's `wolf_json/` folder (maps, common events, databases, `Game.dat`, Evtext, and `names.json`). A checklist lists every JSON file in `wolf_json/`; tick the ones you want and click **Import** (or leave Step 0 — checked files auto-import into the tool's `files/` folder, matching the RPG Maker workflow). Extraction snapshots pristine binaries into `wolf_json/originals/` for idempotent inject in Step 6. |
|
||||
| **1 Pre-process** | Optional: **dazedformat** normalises JSON in `wolf_json/` and `files/` (`json.dump`, indent 4) for clean git diffs; **Copy gameupdate/** installs the updater scripts, patch scripts, and `.gitignore` into the game root for git-based patching. Paths auto-fill from Step 0. |
|
||||
| **2 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. Item/skill/enemy value names (`names.json`) are curated in Step 3 and harvested into `vocab.txt` automatically during Phase 0 — do not list them here. |
|
||||
| **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. WolfDawn tags each name with its WOLF database **category** (`note`), so you pick which *categories* are safe. Copy the **names-safety prompt**, paste the AI's JSON list, **Save** to `data/wolf_safe_notes.json`, pick **Translation mode** (Normal or Batch), and run **Translate safe names (Phase 0)**. |
|
||||
| **4 Translate** | Run the `Wolf RPG (WolfDawn)` module in three ordered phases: **Phase 0 · Names → vocab.txt**, **Phase 1 · Database text**, **Phase 2 · Maps / events** (with speaker handling and text-wrap settings). Only `text` fields are filled in; `source` is preserved for inject drift checks. |
|
||||
| **5 Inject** | Write translations back into the game's `Data/` binaries and refresh git-tracked `wolf_json/`. Uses pristine originals from `wolf_json/originals/`. Quick inject or inject all. |
|
||||
| **6 Package** | Run from loose `Data/` (backs up `Data.wolf` → `.bak`) or repack `Data.wolf`. |
|
||||
| **7 Saves** | *(Optional)* Update existing `.sav` files for the translated build. |
|
||||
| **2 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. Item/skill/enemy value names (`names.json`) are translated in Step 3 and harvested into `vocab.txt` automatically during Phase 0 — do not list them here. |
|
||||
| **3 Names** | Translate `names.json` (item/skill/enemy/map value names). WolfDawn tags each name with a per-entry **safety** badge (`safe`, `refs`, or `verify`). Phase 0 translates only `safe` entries; `refs` and `verify` names stay Japanese so inject skips them. Review the category breakdown for this game, pick **Translation mode** (Normal or Batch), and run **Translate safe names (Phase 0)**. |
|
||||
| **4 Database** | Review the **discovery summary** to see where this game's text lives (standard RPG sheets vs custom dialogue tables). Database sheets are classified as foundation (items, skills, descriptions — translate first) or narrative (custom event/profile sheets — translate after foundation). Use **Translate foundation DB** then **Translate narrative DB**, or tick specific sheets and run **Translate checked sheets only**. Optional: copy the **DB structure prompt** for an AI audit and import the returned JSON into `wolf_json/db_profile.json`. |
|
||||
| **5 Maps/Events** | Translate map scripts (`.mps`), common events (`CommonEvent.dat`), `Game.dat`, and Evtext. Run after Steps 3–4 so vocab and database terms are consistent. Configure speaker handling for low-confidence nameplate guesses. Batch mode is recommended for large `CommonEvent.dat` files. |
|
||||
| **6 Inject** | Tick translated JSON files from `translated/`, then **Inject selected** or **Inject all**. Each file is reported individually; failures open a dialog with details. Uses pristine originals from `wolf_json/originals/`. |
|
||||
| **7 Fix wrap** | Paste overflowing in-game text to **search** `translated/` JSON and jump to the database sheet and row (sheet names match Step 4 and `names.json` notes, e.g. `├■街の噂(MOB)`). Edit the line, set wrap width, **Wrap this row** or **Wrap all overflowing rows in this sheet**, save, then **Inject this file** (usually `DataBase.project.json`). Per-sheet widths are remembered in `wolf_json/wrap_profile.json`. **Advanced:** **wolf relayout** for event message boxes (maps/CommonEvent only — not bulletin-style DB UI) and **wolf desc-relayout** for standard 説明 description fields. |
|
||||
| **8 Package** | Run from loose `Data/` (backs up `Data.wolf` → `.bak`) or repack `Data.wolf`. |
|
||||
| **9 Saves** | *(Optional)* Update existing `.sav` files for the translated build. |
|
||||
|
||||
### Recommended order by game layout
|
||||
|
||||
After Step 0 extract, the **Database** tab discovery report classifies your game:
|
||||
|
||||
| Layout | What to do |
|
||||
|--------|------------|
|
||||
| **DB-heavy** (most dialogue in custom database sheets) | Names → foundation DB → narrative DB → maps/events |
|
||||
| **Classic RPG** (most dialogue in maps/common events) | Names → foundation DB → maps/events (skip narrative DB if none) |
|
||||
| **Hybrid** | Names → foundation DB → narrative DB → maps/events |
|
||||
|
||||
> **`wolf` binary:** Prebuilt `wolf` CLIs for Windows and Linux are bundled offline under `util/wolfdawn/bin/<platform>/`, so no toolchain or build step is needed. They update when you update DazedMTLTool. If your platform's binary is missing, update the tool or ask the maintainer to refresh the bundled copy.
|
||||
|
||||
|
|
|
|||
2
TODO.md
2
TODO.md
|
|
@ -117,3 +117,5 @@ def cmd_usage(client, bid, model):
|
|||
- **Retranslate workflow:** when the user re-imports fresh JSON and runs Translate again, cache hits should fill `translated/` automatically for unchanged source lines; only new or edited source text should incur API cost.
|
||||
- **UI (later):** cache stats (hits / misses / estimated savings), export/import, clear cache for one game, optional “prefer cache” toggle.
|
||||
- **Invalidation:** optional metadata (model id, prompt version) in the key or entry if we need to avoid reusing stale translations after prompt or model changes.
|
||||
|
||||
## According to someone from f95, the patcher breaks if a folder's name has an apostrophe
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -22,21 +22,15 @@ and Japanese embedded only in WOLF control codes (e.g. ``\\r[...]`` ruby).
|
|||
|
||||
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
|
||||
entries whose badge is ``safe`` are translated; ``refs`` and ``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: optional advanced ``wolfWrap`` / ``wolfWidth`` (via :mod:`util.dazedwrap`)
|
||||
can still re-wrap dialogue bodies at translate time, but the normal Wolf workflow
|
||||
leaves JSON line breaks alone and runs WolfDawn ``relayout`` after inject (Step 5)
|
||||
to fit the message box. Defaults to off (``wolfWrap=false``).
|
||||
|
||||
names.json category wrap (Step 3): selected ``note`` categories can be re-wrapped
|
||||
with dazedwrap at a dedicated width (``.env``: ``wolfNameWrap``,
|
||||
``wolfNameWrapWidth``, ``wolfNameWrapNotes``). Other name categories keep the
|
||||
model output verbatim.
|
||||
Database sheet filter (Step 4): optional ``wolfDbIncludeGroups`` or
|
||||
``wolfDbIncludeTiers`` in ``.env`` limit which ``kind: db`` lines are collected.
|
||||
When unset, all database lines are translated.
|
||||
|
||||
Speakers: WolfDawn tags each line with ``speaker`` / ``speaker_src``. For the
|
||||
first-line formats (``literal_line1`` / ``literal_line1_lowconf``) the speaker
|
||||
|
|
@ -57,8 +51,6 @@ import traceback
|
|||
|
||||
from colorama import Fore
|
||||
from tqdm import tqdm
|
||||
|
||||
import util.dazedwrap as dazedwrap
|
||||
from util.paths import PROMPT_PATH, VOCAB_PATH
|
||||
from util.translation import (
|
||||
TranslationConfig,
|
||||
|
|
@ -69,6 +61,7 @@ from util.translation import (
|
|||
from util import speakers as wolf_speakers
|
||||
from util import vocab as wolf_vocab
|
||||
from util.wolfdawn import codes as wolf_codes
|
||||
from util.wolfdawn import db_classify as wolf_db
|
||||
from util.wolfdawn import names as wolf_names
|
||||
|
||||
# Globals (mirror the other engine modules; populated from .env at import time)
|
||||
|
|
@ -105,25 +98,6 @@ _WOLF_CODE_RE = re.compile(
|
|||
# configurable from the workflow (data/wolf_speakers.json).
|
||||
SPEAKER_CONFIG = wolf_speakers.load_config()
|
||||
|
||||
# names.json: translate per-entry safe/refs badges only (see util.wolfdawn.names).
|
||||
|
||||
# Text wrapping: optional advanced rewrap via dazedwrap (wolfWrap/wolfWidth). Defaults
|
||||
# off - the workflow uses WolfDawn relayout after inject instead.
|
||||
WRAP = (os.getenv("wolfWrap", "false").strip().lower() == "true")
|
||||
try:
|
||||
WRAPWIDTH = int(os.getenv("wolfWidth") or 0)
|
||||
except ValueError:
|
||||
WRAPWIDTH = 0
|
||||
|
||||
# names.json: optional per-category dazedwrap (Step 3 workflow UI).
|
||||
NAME_WRAP_ENABLED, NAME_WRAP_WIDTH, NAME_WRAP_NOTES = wolf_names.load_name_wrap_config()
|
||||
|
||||
|
||||
def reload_name_wrap_config() -> None:
|
||||
"""Re-read names.json category wrap settings from ``.env``."""
|
||||
global NAME_WRAP_ENABLED, NAME_WRAP_WIDTH, NAME_WRAP_NOTES
|
||||
NAME_WRAP_ENABLED, NAME_WRAP_WIDTH, NAME_WRAP_NOTES = wolf_names.load_name_wrap_config()
|
||||
|
||||
# Pricing / batching from the configured model
|
||||
PRICING_CONFIG = getPricingConfig(MODEL)
|
||||
INPUTAPICOST = PRICING_CONFIG["inputAPICost"]
|
||||
|
|
@ -162,7 +136,6 @@ def handleWolfDawn(filename, estimate):
|
|||
# Re-read workflow-configured settings so edits made this session take effect
|
||||
# even when translation runs in-process (the module import is cached).
|
||||
SPEAKER_CONFIG = wolf_speakers.load_config()
|
||||
reload_name_wrap_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")
|
||||
|
|
@ -172,8 +145,7 @@ def handleWolfDawn(filename, estimate):
|
|||
translatedData = openFiles(filename)
|
||||
|
||||
# Batch collect only queues API requests; text is still Japanese. Writing
|
||||
# translated/ here would overwrite prior work with rewrapped source (and with
|
||||
# wolfWrap on, the git diff looks like "translations" that never changed).
|
||||
# translated/ here would overwrite prior work with source echoed back.
|
||||
# Real English is written on the consume pass (or a live non-batch run).
|
||||
if not estimate and _batch_phase() != "collect":
|
||||
try:
|
||||
|
|
@ -223,7 +195,14 @@ def collectEntries(data):
|
|||
for line in scene.get("lines") or []:
|
||||
entries.append(line)
|
||||
elif kind == "db":
|
||||
include_tiers, include_groups = wolf_db.load_db_filter_config()
|
||||
json_file = wolf_db.json_file_from_doc(data)
|
||||
for group in data.get("groups") or []:
|
||||
type_name = str(group.get("typeName") or "")
|
||||
if not wolf_db.group_matches_filter(
|
||||
json_file, type_name, include_tiers, include_groups
|
||||
):
|
||||
continue
|
||||
for line in group.get("lines") or []:
|
||||
entries.append(line)
|
||||
elif kind in ("gamedat", "txt"):
|
||||
|
|
@ -240,42 +219,6 @@ def collectEntries(data):
|
|||
return entries
|
||||
|
||||
|
||||
def _wrap_body(body):
|
||||
"""Word-wrap a dialogue body to WRAPWIDTH (no-op when wrapping is disabled)."""
|
||||
if not WRAP or WRAPWIDTH <= 0 or not isinstance(body, str) or not body:
|
||||
return body
|
||||
return dazedwrap.wrapText(body, WRAPWIDTH)
|
||||
|
||||
|
||||
def _wrap_names_entry(text, entry):
|
||||
"""Apply dazedwrap to a names.json entry when its ``note`` is selected in Step 3."""
|
||||
if not NAME_WRAP_ENABLED or NAME_WRAP_WIDTH <= 0:
|
||||
return text
|
||||
if not isinstance(text, str) or not text:
|
||||
return text
|
||||
note = str(entry.get("note", ""))
|
||||
if note not in NAME_WRAP_NOTES:
|
||||
return text
|
||||
return dazedwrap.wrapText(text, NAME_WRAP_WIDTH)
|
||||
|
||||
|
||||
def _wrap_plain(text, is_firstline):
|
||||
"""Wrap a non-reshaped entry, protecting a nameplate first line if present.
|
||||
|
||||
``is_firstline`` is True for first-line-speaker formats that are turned off in
|
||||
the config: their model output is still ``Speaker\\nbody``, so line 1 (the
|
||||
name) is kept intact and only the body is wrapped. Any leading ``@<option>``
|
||||
window prefix is also preserved.
|
||||
"""
|
||||
if not WRAP or WRAPWIDTH <= 0 or not isinstance(text, str) or not text:
|
||||
return text
|
||||
prefix, rest = wolf_speakers.split_window_prefix(text)
|
||||
if is_firstline and "\n" in rest:
|
||||
name, body = rest.split("\n", 1)
|
||||
return prefix + name + "\n" + _wrap_body(body)
|
||||
return prefix + _wrap_body(rest)
|
||||
|
||||
|
||||
def _text_check_body(text: str, speaker_src: str = "") -> str:
|
||||
"""Body text used for skip-translated checks (nameplates / codes ignored).
|
||||
|
||||
|
|
@ -330,13 +273,13 @@ def parseDocument(data, filename):
|
|||
# Fresh extracts keep ``text == source``, so they still queue.
|
||||
if IGNORETLTEXT and not _text_still_needs_translation(e):
|
||||
return False
|
||||
# names.json: only translate WolfDawn safe/refs entries (per-name badge).
|
||||
# names.json: only translate WolfDawn safe entries (per-name badge).
|
||||
if is_names and not wolf_names.is_name_translatable(e):
|
||||
return False
|
||||
return True
|
||||
|
||||
# Only translate entries that still need work; names.json also requires a
|
||||
# safe/refs badge. Untouched leaves keep ``text == source`` so WolfDawn
|
||||
# safe badge. Untouched leaves keep ``text == source`` so WolfDawn
|
||||
# treats them as no-ops on inject.
|
||||
translatable = [e for e in entries if _translatable(e)]
|
||||
|
||||
|
|
@ -392,20 +335,16 @@ def parseDocument(data, filename):
|
|||
if text == src:
|
||||
continue
|
||||
text = wolf_codes.restore_wolf_code_placeholders(text, code_map)
|
||||
if is_names:
|
||||
entry["text"] = _wrap_names_entry(text, entry)
|
||||
elif has_speaker:
|
||||
if has_speaker:
|
||||
speaker_en, body_en = wolf_speakers.parse_prefixed(text)
|
||||
if speaker_en is not None:
|
||||
# Wrap only the body; the name stays on its own line.
|
||||
entry["text"] = wolf_speakers.restore_source(
|
||||
prefix, speaker_en, _wrap_body(body_en)
|
||||
prefix, speaker_en, body_en
|
||||
)
|
||||
else:
|
||||
# Model dropped the [Speaker]: prefix; keep its output as-is.
|
||||
entry["text"] = prefix + _wrap_body(text)
|
||||
entry["text"] = prefix + text
|
||||
else:
|
||||
entry["text"] = _wrap_plain(text, is_firstline)
|
||||
entry["text"] = text
|
||||
wolf_codes.repair_entry(entry)
|
||||
|
||||
# Phase 0 feeds the glossary: harvest safe name values into vocab.txt
|
||||
|
|
|
|||
|
|
@ -59,6 +59,96 @@ class WolfCodesRepairTests(unittest.TestCase):
|
|||
"Fortune-teller\nHa!\\^",
|
||||
)
|
||||
|
||||
def test_apply_font_size_replaces_existing_codes(self):
|
||||
text = r"Go to the \c[21]\f[20]tavern\c[19]\f[18]!"
|
||||
new_text, count = wolf_codes.apply_font_size(text, 16)
|
||||
self.assertEqual(count, 3) # 2 replaced + leading body
|
||||
self.assertTrue(new_text.startswith(r"\f[16]"))
|
||||
self.assertIn(r"\f[16]", new_text)
|
||||
self.assertNotIn(r"\f[20]", new_text)
|
||||
self.assertNotIn(r"\f[18]", new_text)
|
||||
|
||||
def test_apply_font_size_prepends_when_missing(self):
|
||||
text = "Plain text without font codes"
|
||||
new_text, count = wolf_codes.apply_font_size(text, 18)
|
||||
self.assertEqual(count, 1)
|
||||
self.assertTrue(new_text.startswith(r"\f[18]"))
|
||||
|
||||
def test_infer_base_font_prefers_body_over_emphasis(self):
|
||||
text = r"So they even take worries\nlike this in the\n\c[21]\f[20]confessional\c[19]\f[18]..."
|
||||
self.assertEqual(wolf_codes.infer_base_font_size(text), 18)
|
||||
|
||||
def test_scale_font_sizes_keeps_emphasis_ratio(self):
|
||||
text = (
|
||||
r"So they even take worries\nlike this in the\n"
|
||||
r"\c[21]\f[20]confessional\c[19]\f[18]..."
|
||||
)
|
||||
new_text, count = wolf_codes.scale_font_sizes(text, 14)
|
||||
self.assertEqual(count, 3) # 2 scaled + leading body insert
|
||||
# 20 * 14/18 ≈ 15.56 → 16; 18 * 14/18 = 14
|
||||
self.assertTrue(new_text.startswith(r"\f[14]"))
|
||||
self.assertIn(r"\f[16]", new_text)
|
||||
self.assertIn(r"\f[14]", new_text)
|
||||
self.assertNotIn(r"\f[20]", new_text)
|
||||
self.assertNotIn(r"\f[18]", new_text)
|
||||
|
||||
def test_scale_font_sizes_matches_names_wrap_style_shrink(self):
|
||||
source_style = r"\c[21]\f[20]Brothel\c[19]\f[18] doesn't get much use"
|
||||
new_text, _ = wolf_codes.scale_font_sizes(source_style, 17)
|
||||
# 20*17/18 ≈ 18.89 → 19; body 17
|
||||
self.assertTrue(new_text.startswith(r"\f[17]"))
|
||||
self.assertIn(r"\f[19]", new_text)
|
||||
self.assertIn(r"\f[17]", new_text)
|
||||
|
||||
def test_scale_font_sizes_leads_with_body_for_midline_emphasis(self):
|
||||
# Cafe-style: body only appears after emphasis; Wolf needs \\f at start.
|
||||
text = (
|
||||
'"That \\c[21]\\f[14]cafe\\c[19]\\f[13] over there has been\n'
|
||||
'packed lately." "Apparently\n'
|
||||
"they're hiring waitresses.\""
|
||||
)
|
||||
new_text, count = wolf_codes.scale_font_sizes(text, 13)
|
||||
self.assertGreaterEqual(count, 2)
|
||||
self.assertTrue(new_text.startswith(r"\f[13]"))
|
||||
self.assertIn(r"\c[21]\f[14]cafe\c[19]\f[13]", new_text)
|
||||
|
||||
def test_ensure_leading_font_size_idempotent(self):
|
||||
text = r'\f[13]"That \c[21]\f[14]cafe\c[19]\f[13] over there"'
|
||||
self.assertEqual(wolf_codes.ensure_leading_font_size(text, 13), text)
|
||||
|
||||
def test_apply_font_size_also_leads(self):
|
||||
text = r'"That \c[21]\f[20]cafe\c[19]\f[18] over there"'
|
||||
new_text, _ = wolf_codes.apply_font_size(text, 13)
|
||||
self.assertTrue(new_text.startswith(r"\f[13]"))
|
||||
self.assertNotIn(r"\f[20]", new_text)
|
||||
|
||||
def test_font_size_codes_differ_detects_names_wrap_shrink(self):
|
||||
source = (
|
||||
r"「おい!例の\c[21]\f[20]教会の特別奉仕活動\c[19]\f[18]があるってよ!」"
|
||||
)
|
||||
text = (
|
||||
r'\f[14]"Hey! Apparently there\'s one of those '
|
||||
r'\c[21]\f[16]Special Service Activities at the church\c[19]\f[14]!"'
|
||||
)
|
||||
self.assertTrue(wolf_codes.font_size_codes_differ(source, text))
|
||||
|
||||
def test_font_size_codes_differ_false_when_color_missing(self):
|
||||
source = r"\c[21]\f[20]娼館\c[19]\f[18]"
|
||||
text = r"\f[14]Brothel" # dropped colour codes
|
||||
self.assertFalse(wolf_codes.font_size_codes_differ(source, text))
|
||||
|
||||
def test_names_doc_has_font_size_drift(self):
|
||||
doc = {
|
||||
"kind": "names",
|
||||
"names": [
|
||||
{
|
||||
"source": r"\c[21]\f[20]娼館\c[19]\f[18]",
|
||||
"text": r"\f[14]\c[21]\f[16]Brothel\c[19]\f[14]",
|
||||
}
|
||||
],
|
||||
}
|
||||
self.assertTrue(wolf_codes.names_doc_has_font_size_drift(doc))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
227
tests/test_wolf_db_classify.py
Normal file
227
tests/test_wolf_db_classify.py
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Unit tests for WolfDawn database classification in util/wolfdawn/db_classify.py."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
os.chdir(ROOT)
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from util.wolfdawn import db_classify as wdb # noqa: E402
|
||||
|
||||
|
||||
STANDARD_DB = {
|
||||
"file": "DataBase.project",
|
||||
"kind": "db",
|
||||
"groups": [
|
||||
{
|
||||
"type": 0,
|
||||
"typeName": "Skill · 技能",
|
||||
"lines": [
|
||||
{
|
||||
"row": 2,
|
||||
"field": 1,
|
||||
"fieldName": "Description · 説明",
|
||||
"source": "味方一人のHPを回復",
|
||||
"text": "味方一人のHPを回復",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"type": 1,
|
||||
"typeName": "Item · アイテム",
|
||||
"lines": [
|
||||
{
|
||||
"row": 0,
|
||||
"field": 1,
|
||||
"fieldName": "Description · 説明",
|
||||
"source": "HPを回復",
|
||||
"text": "HPを回復",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
NARRATIVE_DB = {
|
||||
"file": "DataBase.project",
|
||||
"kind": "db",
|
||||
"groups": [
|
||||
{
|
||||
"type": 10,
|
||||
"typeName": "■イベント(セルリア)",
|
||||
"lines": [
|
||||
{
|
||||
"row": 0,
|
||||
"field": 3,
|
||||
"fieldName": "セリフ_メッセージ",
|
||||
"source": "こんにちは",
|
||||
"text": "こんにちは",
|
||||
},
|
||||
{
|
||||
"row": 0,
|
||||
"field": 4,
|
||||
"fieldName": "直前の相手_コメント",
|
||||
"source": "誰か",
|
||||
"text": "誰か",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"type": 11,
|
||||
"typeName": "├■プロフィール(元数値)",
|
||||
"lines": [
|
||||
{
|
||||
"row": 1,
|
||||
"field": 2,
|
||||
"fieldName": "好きなもの_コメント",
|
||||
"source": "甘いもの",
|
||||
"text": "甘いもの",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class TestClassifyGroupTier(unittest.TestCase):
|
||||
def test_standard_skill_sheet_is_foundation(self):
|
||||
tier = wdb.classify_group_tier(
|
||||
"Skill · 技能",
|
||||
STANDARD_DB["groups"][0]["lines"],
|
||||
)
|
||||
self.assertEqual(tier, wdb.TIER_FOUNDATION)
|
||||
|
||||
def test_custom_event_sheet_is_narrative(self):
|
||||
tier = wdb.classify_group_tier(
|
||||
"■イベント(セルリア)",
|
||||
NARRATIVE_DB["groups"][0]["lines"],
|
||||
)
|
||||
self.assertEqual(tier, wdb.TIER_NARRATIVE)
|
||||
|
||||
def test_profile_sheet_is_narrative(self):
|
||||
tier = wdb.classify_group_tier(
|
||||
"├■プロフィール(元数値)",
|
||||
NARRATIVE_DB["groups"][1]["lines"],
|
||||
)
|
||||
self.assertEqual(tier, wdb.TIER_NARRATIVE)
|
||||
|
||||
|
||||
class TestClassifyDbDocument(unittest.TestCase):
|
||||
def test_standard_doc_groups(self):
|
||||
groups = wdb.classify_db_document(STANDARD_DB)
|
||||
self.assertEqual(len(groups), 2)
|
||||
self.assertTrue(all(g.tier == wdb.TIER_FOUNDATION for g in groups))
|
||||
self.assertTrue(all(g.default_checked for g in groups))
|
||||
|
||||
def test_narrative_doc_groups(self):
|
||||
groups = wdb.classify_db_document(NARRATIVE_DB)
|
||||
self.assertEqual(len(groups), 2)
|
||||
self.assertTrue(all(g.tier == wdb.TIER_NARRATIVE for g in groups))
|
||||
self.assertTrue(all(not g.default_checked for g in groups))
|
||||
|
||||
|
||||
class TestContentDistribution(unittest.TestCase):
|
||||
def test_analyze_mixed_files_dir(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
base = Path(tmp)
|
||||
(base / "DataBase.project.json").write_text(
|
||||
json.dumps({**STANDARD_DB, **NARRATIVE_DB, "groups": (
|
||||
STANDARD_DB["groups"] + NARRATIVE_DB["groups"]
|
||||
)}, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(base / "OP.mps.json").write_text(
|
||||
json.dumps({
|
||||
"file": "OP.mps",
|
||||
"kind": "map",
|
||||
"scenes": [{"lines": [{"source": "a", "text": "a"}]}],
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
dist = wdb.analyze_content_distribution(base)
|
||||
self.assertGreater(dist.db_lines, 0)
|
||||
self.assertGreater(dist.db_foundation_lines, 0)
|
||||
self.assertGreater(dist.db_narrative_lines, 0)
|
||||
self.assertEqual(dist.map_files, 1)
|
||||
self.assertEqual(dist.map_lines, 1)
|
||||
|
||||
def test_discovery_summary_mentions_database(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
base = Path(tmp)
|
||||
(base / "DataBase.project.json").write_text(
|
||||
json.dumps(NARRATIVE_DB, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
dist = wdb.analyze_content_distribution(base)
|
||||
summary = wdb.format_discovery_summary(dist)
|
||||
self.assertIn("Database:", summary)
|
||||
self.assertIn("Recommended order:", summary)
|
||||
|
||||
|
||||
class TestGroupFilter(unittest.TestCase):
|
||||
def test_group_key_format(self):
|
||||
key = wdb.group_key("DataBase.project.json", "Skill · 技能")
|
||||
self.assertEqual(key, "DataBase.project.json|Skill · 技能")
|
||||
|
||||
def test_filter_by_groups(self):
|
||||
groups = frozenset({"DataBase.project.json|Skill · 技能"})
|
||||
self.assertTrue(
|
||||
wdb.group_matches_filter(
|
||||
"DataBase.project.json", "Skill · 技能", frozenset(), groups
|
||||
)
|
||||
)
|
||||
self.assertFalse(
|
||||
wdb.group_matches_filter(
|
||||
"DataBase.project.json", "■イベント(セルリア)", frozenset(), groups
|
||||
)
|
||||
)
|
||||
|
||||
def test_empty_filter_matches_all(self):
|
||||
self.assertTrue(
|
||||
wdb.group_matches_filter(
|
||||
"DataBase.project.json", "■イベント(セルリア)", frozenset(), frozenset()
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class TestDbProfile(unittest.TestCase):
|
||||
def test_save_and_load_profile(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
work = Path(tmp)
|
||||
profile = {
|
||||
"archetype": "simulation_db_heavy",
|
||||
"foundation_groups": ["DataBase.project.json|Skill · 技能"],
|
||||
"narrative_groups": ["DataBase.project.json|■イベント(セルリア)"],
|
||||
"defer_groups": [],
|
||||
"notes": "Test note",
|
||||
}
|
||||
wdb.save_db_profile(work, profile)
|
||||
loaded = wdb.load_db_profile(work)
|
||||
self.assertEqual(loaded["archetype"], "simulation_db_heavy")
|
||||
self.assertEqual(len(loaded["foundation_groups"]), 1)
|
||||
|
||||
def test_import_ai_profile_strips_fences(self):
|
||||
raw = '```json\n{"archetype": "hybrid", "foundation_groups": []}\n```'
|
||||
data = wdb.import_ai_profile(raw)
|
||||
self.assertEqual(data["archetype"], "hybrid")
|
||||
|
||||
def test_merge_profile_checks(self):
|
||||
groups = wdb.classify_db_document(NARRATIVE_DB)
|
||||
checks = wdb.merge_profile_with_groups(
|
||||
{"narrative_groups": [groups[0].key]},
|
||||
groups,
|
||||
)
|
||||
self.assertTrue(checks[groups[0].key])
|
||||
self.assertFalse(checks[groups[1].key])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
127
tests/test_wolf_inject_batch.py
Normal file
127
tests/test_wolf_inject_batch.py
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Unit tests for util/wolfdawn/inject.py orchestration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
os.chdir(ROOT)
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from util.wolfdawn import inject as wi # noqa: E402
|
||||
|
||||
|
||||
class ListInjectableTests(unittest.TestCase):
|
||||
def test_lists_translated_files_with_manifest_entries(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
translated = root / "translated"
|
||||
translated.mkdir()
|
||||
(translated / "names.json").write_text("{}", encoding="utf-8")
|
||||
(translated / "Map001.mps.json").write_text("{}", encoding="utf-8")
|
||||
(translated / "orphan.json").write_text("{}", encoding="utf-8")
|
||||
entries = [
|
||||
{"json": "names.json", "kind": "names", "base": "/data"},
|
||||
{"json": "Map001.mps.json", "kind": "map", "base": "/data/Map001.mps"},
|
||||
]
|
||||
self.assertEqual(
|
||||
wi.list_injectable(translated, entries),
|
||||
["Map001.mps.json", "names.json"],
|
||||
)
|
||||
|
||||
|
||||
class InjectOrderTests(unittest.TestCase):
|
||||
def test_names_runs_before_strings(self):
|
||||
calls: list[str] = []
|
||||
|
||||
def fake_names(*_a, **_k):
|
||||
calls.append("names")
|
||||
return mock.Mock(
|
||||
ok=True,
|
||||
returncode=0,
|
||||
stdout="applied 2 name change(s) (0 drifted/unmatched)",
|
||||
stderr="",
|
||||
)
|
||||
|
||||
def fake_strings(*_a, **_k):
|
||||
calls.append("strings")
|
||||
return mock.Mock(
|
||||
ok=True,
|
||||
returncode=0,
|
||||
stdout="applied 1 translation(s) (0 drifted)",
|
||||
stderr="",
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
translated = root / "translated"
|
||||
originals = root / "originals"
|
||||
data = root / "data"
|
||||
translated.mkdir()
|
||||
originals.mkdir()
|
||||
data.mkdir()
|
||||
(translated / "names.json").write_text("{}", encoding="utf-8")
|
||||
(translated / "Map001.mps.json").write_text(
|
||||
'{"kind":"map","scenes":[]}', encoding="utf-8"
|
||||
)
|
||||
map_live = data / "Map001.mps"
|
||||
map_orig = originals / "Map001.mps"
|
||||
map_live.write_bytes(b"live")
|
||||
map_orig.write_bytes(b"orig")
|
||||
|
||||
entries = [
|
||||
{"json": "names.json", "kind": "names", "base": str(data)},
|
||||
{"json": "Map001.mps.json", "kind": "map", "base": str(map_live)},
|
||||
]
|
||||
|
||||
def would_apply_counts():
|
||||
yield 1 # prepare: after restore
|
||||
yield 1 # _inject_names: preflight
|
||||
yield 0 # _inject_names: post-check
|
||||
|
||||
counts = would_apply_counts()
|
||||
|
||||
def next_would_apply(*_a, **_k):
|
||||
return next(counts, 0)
|
||||
|
||||
with mock.patch.object(wi.wolf_originals, "names_inject_would_apply", side_effect=next_would_apply), \
|
||||
mock.patch.object(wi.wolfdawn, "names_inject", side_effect=fake_names), \
|
||||
mock.patch.object(wi.wolfdawn, "strings_inject", side_effect=fake_strings):
|
||||
report = wi.inject_selected(
|
||||
["Map001.mps.json", wi.NAMES_JSON],
|
||||
manifest_entries=entries,
|
||||
data_dir=data,
|
||||
originals_dir=originals,
|
||||
translated_dir=translated,
|
||||
game_root=data.parent,
|
||||
)
|
||||
|
||||
self.assertEqual(calls, ["names", "strings"])
|
||||
self.assertTrue(report.ok)
|
||||
self.assertEqual(len(report.succeeded), 2)
|
||||
|
||||
|
||||
class ReportDialogTests(unittest.TestCase):
|
||||
def test_failure_dialog_lists_every_problem(self):
|
||||
report = wi.InjectReport(
|
||||
files=[
|
||||
wi.FileInjectResult("a.json", True, "applied 3 line(s)"),
|
||||
wi.FileInjectResult("b.json", False, "0 applied, 2 drifted", detail="stderr"),
|
||||
],
|
||||
sync_failures=[("c.json", "permission denied")],
|
||||
)
|
||||
title, body = wi.format_report_dialog(report)
|
||||
self.assertIn("errors", title)
|
||||
self.assertIn("✗ b.json", body)
|
||||
self.assertIn("✗ sync c.json", body)
|
||||
self.assertIn("✓ a.json", body)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -27,6 +27,13 @@ class WolfInjectCountsTests(unittest.TestCase):
|
|||
self.assertEqual(drifted, 0)
|
||||
self.assertTrue(wolfdawn.inject_had_applied(applied))
|
||||
|
||||
def test_parse_names_inject_dry_run_counts(self):
|
||||
out = "would apply 0 name change(s) (0 drifted/unmatched); dry run — did NOT write"
|
||||
applied, drifted = wolfdawn.parse_names_inject_counts(out)
|
||||
self.assertEqual(applied, 0)
|
||||
self.assertEqual(drifted, 0)
|
||||
self.assertFalse(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)
|
||||
|
|
@ -51,6 +58,18 @@ class WolfInjectCountsTests(unittest.TestCase):
|
|||
self.assertFalse(wolfdawn.inject_had_applied(0))
|
||||
self.assertFalse(wolfdawn.inject_had_applied(None))
|
||||
|
||||
def test_parse_inject_cli_error(self):
|
||||
err = wolfdawn.parse_inject_cli_error(
|
||||
"", "strings-inject: cannot read input or DB pair\n"
|
||||
)
|
||||
self.assertEqual(err, "cannot read input or DB pair")
|
||||
|
||||
def test_db_dat_sibling(self):
|
||||
self.assertEqual(
|
||||
wolfdawn.db_dat_sibling("BasicData/DataBase.project").name,
|
||||
"DataBase.dat",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -18,11 +18,11 @@ from util.wolfdawn import names as wn # noqa: E402
|
|||
|
||||
|
||||
class TestNameTranslatable(unittest.TestCase):
|
||||
def test_safe_and_refs_are_translatable(self):
|
||||
def test_safe_is_translatable(self):
|
||||
self.assertTrue(wn.is_name_translatable({"safety": "safe"}))
|
||||
self.assertTrue(wn.is_name_translatable({"safety": "refs"}))
|
||||
|
||||
def test_verify_and_missing_are_not_translatable(self):
|
||||
def test_refs_verify_and_missing_are_not_translatable(self):
|
||||
self.assertFalse(wn.is_name_translatable({"safety": "refs"}))
|
||||
self.assertFalse(wn.is_name_translatable({"safety": "verify"}))
|
||||
self.assertFalse(wn.is_name_translatable({}))
|
||||
self.assertFalse(wn.is_name_translatable({"safety": ""}))
|
||||
|
|
@ -67,11 +67,11 @@ class TestCountNameSafety(unittest.TestCase):
|
|||
self.assertEqual(counts["refs"], 1)
|
||||
self.assertEqual(counts["verify"], 1)
|
||||
self.assertEqual(counts["unknown"], 1)
|
||||
self.assertEqual(counts["translatable"], 2)
|
||||
self.assertEqual(counts["translatable"], 1)
|
||||
|
||||
def test_summary_mentions_translatable_count(self):
|
||||
summary = wn.format_name_safety_summary(self.DOC)
|
||||
self.assertIn("2 of 4", summary)
|
||||
self.assertIn("1 of 4", summary)
|
||||
self.assertIn("verify", summary)
|
||||
|
||||
|
||||
|
|
@ -101,19 +101,6 @@ class TestCollectNameNotes(unittest.TestCase):
|
|||
def test_collects_unique_sorted_notes(self):
|
||||
self.assertEqual(wn.collect_name_notes(self.DOC), ["技能", "武器"])
|
||||
|
||||
def test_parse_json_notes(self):
|
||||
self.assertEqual(
|
||||
wn.parse_name_wrap_notes('["武器","├■プロフィール"]'),
|
||||
frozenset({"武器", "├■プロフィール"}),
|
||||
)
|
||||
|
||||
def test_parse_comma_separated_notes(self):
|
||||
self.assertEqual(
|
||||
wn.parse_name_wrap_notes("武器,技能"),
|
||||
frozenset({"武器", "技能"}),
|
||||
)
|
||||
|
||||
|
||||
class TestDeriveDbLabels(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
|
|
@ -140,5 +127,46 @@ class TestDeriveDbLabels(unittest.TestCase):
|
|||
self.assertEqual(wn.derive_db_labels(self.dir / "nope"), {})
|
||||
|
||||
|
||||
class TestNameWrapRoles(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.dir = Path(self._tmp.name)
|
||||
self.names = self.dir / "names.json"
|
||||
self.names.write_text("{}", encoding="utf-8")
|
||||
|
||||
def tearDown(self):
|
||||
self._tmp.cleanup()
|
||||
|
||||
def test_roles_path_beside_names(self):
|
||||
self.assertEqual(
|
||||
wn.roles_json_path_for_names(self.names),
|
||||
self.dir / "wolfdawn-roles.json",
|
||||
)
|
||||
|
||||
def test_upsert_and_load_name_wrap_role(self):
|
||||
roles_path = wn.roles_json_path_for_names(self.names)
|
||||
wn.upsert_name_wrap_role(roles_path, "説明", width=48, max_lines=3)
|
||||
roles = wn.load_name_wrap_roles(roles_path)
|
||||
role = wn.get_name_wrap_role(roles, "説明")
|
||||
self.assertIsNotNone(role)
|
||||
assert role is not None
|
||||
self.assertEqual(role["width"], 48)
|
||||
self.assertEqual(role["maxLines"], 3)
|
||||
|
||||
def test_upsert_updates_existing_note(self):
|
||||
roles_path = wn.roles_json_path_for_names(self.names)
|
||||
wn.upsert_name_wrap_role(roles_path, "説明", width=40)
|
||||
wn.upsert_name_wrap_role(roles_path, "説明", width=52, max_lines=4)
|
||||
role = wn.get_name_wrap_role(wn.load_name_wrap_roles(roles_path), "説明")
|
||||
self.assertEqual(role["width"], 52)
|
||||
self.assertEqual(role["maxLines"], 4)
|
||||
|
||||
def test_upsert_persists_font(self):
|
||||
roles_path = wn.roles_json_path_for_names(self.names)
|
||||
wn.upsert_name_wrap_role(roles_path, "説明", width=40, font=14)
|
||||
role = wn.get_name_wrap_role(wn.load_name_wrap_roles(roles_path), "説明")
|
||||
self.assertEqual(role["font"], 14)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
127
tests/test_wolf_selective_wrap.py
Normal file
127
tests/test_wolf_selective_wrap.py
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Unit tests for selective DB wrap in util/wolfdawn/selective_wrap.py."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
os.chdir(ROOT)
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from util.wolfdawn import selective_wrap as sw # noqa: E402
|
||||
import util.dazedwrap as dazedwrap # noqa: E402
|
||||
|
||||
|
||||
SAMPLE = {
|
||||
"file": "DataBase.project",
|
||||
"kind": "db",
|
||||
"groups": [
|
||||
{
|
||||
"type": 10,
|
||||
"typeName": "■イベント(テスト)",
|
||||
"lines": [
|
||||
{
|
||||
"row": 0,
|
||||
"field": 1,
|
||||
"fieldName": "好きなもの_コメント",
|
||||
"source": "短い",
|
||||
"text": "Short text that fits",
|
||||
},
|
||||
{
|
||||
"row": 1,
|
||||
"field": 1,
|
||||
"fieldName": "好きなもの_コメント",
|
||||
"source": "長い",
|
||||
"text": (
|
||||
"This is a very long English comment that definitely "
|
||||
"exceeds forty characters in a single line"
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"type": 0,
|
||||
"typeName": "Item · アイテム",
|
||||
"lines": [
|
||||
{
|
||||
"row": 0,
|
||||
"field": 1,
|
||||
"fieldName": "Description · 説明",
|
||||
"source": "x",
|
||||
"text": "Also a long item description that should wrap when selected",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class TestLineNeedsWrap(unittest.TestCase):
|
||||
def test_short_line_skipped(self):
|
||||
self.assertFalse(sw.line_needs_wrap("Short", 40, min_visible=20))
|
||||
|
||||
def test_long_line_needs_wrap(self):
|
||||
text = "A" * 50
|
||||
self.assertTrue(sw.line_needs_wrap(text, 40))
|
||||
|
||||
def test_color_and_font_codes_do_not_count_toward_width(self):
|
||||
text = r"Hello \c[21]\f[20]tavern\c[19] end"
|
||||
self.assertFalse(sw.line_needs_wrap(text, 20))
|
||||
self.assertTrue(sw.line_needs_wrap(text, 10))
|
||||
|
||||
def test_wrap_preserves_inline_codes(self):
|
||||
text = r"Go to the \c[21]\f[20]tavern\c[19]\f[18] tonight for fun"
|
||||
wrapped = sw.wrap_line_text(text, 18)
|
||||
self.assertIn(r"\c[21]", wrapped)
|
||||
self.assertIn(r"\f[20]", wrapped)
|
||||
for line in wrapped.split("\n"):
|
||||
self.assertLessEqual(dazedwrap._get_visible_length(line), 18)
|
||||
|
||||
|
||||
class TestWrapDbDocument(unittest.TestCase):
|
||||
def test_only_checked_sheet_and_field(self):
|
||||
keys = frozenset({"DataBase.project.json|■イベント(テスト)"})
|
||||
pattern = sw.FIELD_PRESET_COMMENTS
|
||||
doc = json.loads(json.dumps(SAMPLE))
|
||||
wrapped, skipped = sw.wrap_db_document(
|
||||
doc,
|
||||
group_keys=keys,
|
||||
field_pattern=pattern,
|
||||
width=30,
|
||||
min_visible=0,
|
||||
)
|
||||
self.assertEqual(wrapped, 1)
|
||||
long_line = doc["groups"][0]["lines"][1]["text"]
|
||||
self.assertIn("\n", long_line)
|
||||
short_line = doc["groups"][0]["lines"][0]["text"]
|
||||
self.assertNotIn("\n", short_line)
|
||||
|
||||
def test_group_type_indices(self):
|
||||
keys = frozenset({"DataBase.project.json|■イベント(テスト)"})
|
||||
self.assertEqual(sw.group_type_indices(SAMPLE, keys), [10])
|
||||
|
||||
|
||||
class TestWrapTranslatedDir(unittest.TestCase):
|
||||
def test_writes_touched_file(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "DataBase.project.json"
|
||||
path.write_text(json.dumps(SAMPLE), encoding="utf-8")
|
||||
result = sw.wrap_db_translated_dir(
|
||||
tmp,
|
||||
group_keys=frozenset({"DataBase.project.json|■イベント(テスト)"}),
|
||||
field_presets=frozenset({"comments"}),
|
||||
width=30,
|
||||
min_visible=0,
|
||||
)
|
||||
self.assertEqual(result.lines_wrapped, 1)
|
||||
self.assertEqual(result.files_touched, 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
440
tests/test_wolf_wrap_search.py
Normal file
440
tests/test_wolf_wrap_search.py
Normal file
|
|
@ -0,0 +1,440 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Unit tests for search-first wrap workflow (util/wolfdawn/wrap_search.py)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
os.chdir(ROOT)
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
import util.dazedwrap as dazedwrap # noqa: E402
|
||||
from util.wolfdawn import wrap_search as ws # noqa: E402
|
||||
|
||||
RUMOR_SHEET = "├■街の噂(MOB)"
|
||||
GATEKEEPER_TEXT = (
|
||||
"That gatekeeper just randomly hits on any pretty woman he sees. "
|
||||
"They say he once tried to pick up a horned rabbit."
|
||||
)
|
||||
GATEKEEPER_ROW = 26
|
||||
GATEKEEPER_FIELD = "ロ:町のウワサ0"
|
||||
|
||||
RUMOR_FIXTURE = {
|
||||
"file": "DataBase.project",
|
||||
"kind": "db",
|
||||
"groups": [
|
||||
{
|
||||
"type": 29,
|
||||
"typeName": RUMOR_SHEET,
|
||||
"lines": [
|
||||
{
|
||||
"row": 25,
|
||||
"field": 1,
|
||||
"fieldName": "ロ:町のウワサ0",
|
||||
"source": "別の噂",
|
||||
"text": "Short rumor that fits fine.",
|
||||
},
|
||||
{
|
||||
"row": GATEKEEPER_ROW,
|
||||
"field": 1,
|
||||
"fieldName": GATEKEEPER_FIELD,
|
||||
"source": "門番",
|
||||
"text": GATEKEEPER_TEXT,
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class TestWolfVisibleLength(unittest.TestCase):
|
||||
def test_font_codes_do_not_count_toward_width(self):
|
||||
plain = "Hello world"
|
||||
coded = r"Hello \f[2]world"
|
||||
self.assertEqual(
|
||||
dazedwrap.max_line_visible_length(plain),
|
||||
dazedwrap.max_line_visible_length(coded),
|
||||
)
|
||||
|
||||
def test_gatekeeper_line_is_overflow_at_bulletin_width(self):
|
||||
length = dazedwrap.max_line_visible_length(GATEKEEPER_TEXT)
|
||||
self.assertGreater(length, 40)
|
||||
self.assertGreater(length, 34)
|
||||
|
||||
|
||||
class TestWrapSearch(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.translated = Path(self.tmp.name) / "translated"
|
||||
self.translated.mkdir()
|
||||
self.work = Path(self.tmp.name) / "wolf_json"
|
||||
self.work.mkdir()
|
||||
path = self.translated / "DataBase.project.json"
|
||||
path.write_text(json.dumps(RUMOR_FIXTURE, ensure_ascii=False, indent=4), encoding="utf-8")
|
||||
|
||||
def tearDown(self):
|
||||
self.tmp.cleanup()
|
||||
|
||||
def test_search_finds_gatekeeper_in_rumor_sheet(self):
|
||||
hits = ws.search_translated_text("gatekeeper just randomly", self.translated)
|
||||
self.assertEqual(len(hits), 1)
|
||||
hit = hits[0]
|
||||
self.assertEqual(hit.kind, "db")
|
||||
self.assertEqual(hit.sheet_name, RUMOR_SHEET)
|
||||
self.assertEqual(hit.row, GATEKEEPER_ROW)
|
||||
self.assertEqual(hit.field_name, GATEKEEPER_FIELD)
|
||||
self.assertEqual(hit.json_file, "DataBase.project.json")
|
||||
self.assertIn("gatekeeper", hit.text.lower())
|
||||
|
||||
def test_search_finds_horned_rabbits_same_row(self):
|
||||
hits = ws.search_translated_text("horned rabbit", self.translated)
|
||||
self.assertEqual(len(hits), 1)
|
||||
self.assertEqual(hits[0].row, GATEKEEPER_ROW)
|
||||
|
||||
def test_wrap_overflow_in_sheet_changes_long_lines(self):
|
||||
path = self.translated / "DataBase.project.json"
|
||||
doc = json.loads(path.read_text(encoding="utf-8"))
|
||||
changed = ws.wrap_overflow_in_sheet(path, doc, RUMOR_SHEET, width=34)
|
||||
self.assertEqual(changed, 1)
|
||||
doc2 = json.loads(path.read_text(encoding="utf-8"))
|
||||
line = ws.locate_line(
|
||||
doc2,
|
||||
{
|
||||
"kind": "db",
|
||||
"sheet_name": RUMOR_SHEET,
|
||||
"row": GATEKEEPER_ROW,
|
||||
"field_name": GATEKEEPER_FIELD,
|
||||
},
|
||||
)
|
||||
self.assertIsNotNone(line)
|
||||
assert line is not None
|
||||
self.assertIn("\n", line["text"])
|
||||
self.assertLessEqual(dazedwrap.max_line_visible_length(line["text"]), 34)
|
||||
|
||||
def test_wrap_profile_remembers_sheet_width(self):
|
||||
ws.set_sheet_width(self.work, RUMOR_SHEET, 34, json_file="DataBase.project.json")
|
||||
profile = ws.load_wrap_profile(self.work)
|
||||
self.assertEqual(ws.get_sheet_width(profile, RUMOR_SHEET), 34)
|
||||
entry = profile["sheets"][RUMOR_SHEET]
|
||||
self.assertEqual(entry["json_file"], "DataBase.project.json")
|
||||
|
||||
def test_sheet_overflow_summary_counts_gatekeeper_row(self):
|
||||
summaries = ws.sheet_overflow_summaries(self.translated, width=34)
|
||||
rumor = [s for s in summaries if s.sheet_name == RUMOR_SHEET]
|
||||
self.assertEqual(len(rumor), 1)
|
||||
self.assertEqual(rumor[0].overflow_count, 1)
|
||||
self.assertEqual(rumor[0].line_count, 2)
|
||||
|
||||
|
||||
class TestNamesSearch(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.translated = Path(self.tmp.name) / "translated"
|
||||
self.translated.mkdir()
|
||||
fixture = {
|
||||
"kind": "names",
|
||||
"count": 1,
|
||||
"names": [
|
||||
{
|
||||
"source": "ブロthel scout line",
|
||||
"text": (
|
||||
"Maybe I'll go scout out some newcomers at the brothel.\n"
|
||||
"\u3000Hope there's a pure-looking girl with big boobs..."
|
||||
),
|
||||
"note": "Profile · プロフィール",
|
||||
"safety": "safe",
|
||||
}
|
||||
],
|
||||
}
|
||||
(self.translated / "names.json").write_text(
|
||||
json.dumps(fixture, ensure_ascii=False, indent=4),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
self.tmp.cleanup()
|
||||
|
||||
def test_search_finds_names_json_profile_text(self):
|
||||
hits = ws.search_translated_text("there's a pure", self.translated)
|
||||
self.assertEqual(len(hits), 1)
|
||||
hit = hits[0]
|
||||
self.assertEqual(hit.kind, "names")
|
||||
self.assertEqual(hit.json_file, "names.json")
|
||||
self.assertIn("pure-looking", hit.text)
|
||||
|
||||
def test_load_hit_from_names_index(self):
|
||||
hits = ws.search_translated_text("pure-looking", self.translated)
|
||||
self.assertEqual(len(hits), 1)
|
||||
path, doc, line = ws.load_hit_from_id(self.translated, hits[0].hit_id)
|
||||
self.assertIsNotNone(path)
|
||||
assert path is not None and line is not None
|
||||
self.assertEqual(path.name, "names.json")
|
||||
self.assertIn("pure-looking", line["text"])
|
||||
|
||||
|
||||
class TestWrapPreview(unittest.TestCase):
|
||||
SAMPLE = (
|
||||
'"Apparently there\'s a super hot female adventurer who just '
|
||||
'came to town!? Maybe I can meet her if I head to the '
|
||||
r'\c[21]\f[20]tavern\c[19]\f[18]!"'
|
||||
)
|
||||
|
||||
def test_preview_detects_wrap_needed(self):
|
||||
info = ws.wrap_preview_info(self.SAMPLE, 40)
|
||||
self.assertTrue(info["needs_wrap"])
|
||||
self.assertGreater(info["longest"], 40)
|
||||
self.assertGreater(info["output_line_count"], 1)
|
||||
|
||||
def test_format_preview_shows_line_counts(self):
|
||||
preview = ws.format_wrap_preview(self.SAMPLE, 40)
|
||||
self.assertIn("(40)", preview)
|
||||
self.assertIn("Apparently", preview)
|
||||
|
||||
def test_split_line_at_visible_width(self):
|
||||
line = "Apparently there's a super hot female adventurer who just"
|
||||
fit_end, line_len = ws.split_line_at_visible_width(line, 40)
|
||||
self.assertEqual(line_len, len(line))
|
||||
self.assertGreater(fit_end, 0)
|
||||
self.assertLess(fit_end, line_len)
|
||||
self.assertLessEqual(
|
||||
dazedwrap.max_line_visible_length(line[:fit_end]),
|
||||
40,
|
||||
)
|
||||
|
||||
def test_codes_do_not_inflate_visible_counts(self):
|
||||
text = r'See the \c[21]\f[20]tavern\c[19]\f[18] on Main Street'
|
||||
info = ws.wrap_preview_info(text, 30)
|
||||
self.assertLess(info["longest"], len(text))
|
||||
self.assertLessEqual(info["longest"], 30 + 10)
|
||||
|
||||
def test_render_wolf_text_html_hides_codes_and_styles(self):
|
||||
text = r'That \c[21]\f[20]cafe\c[19]\f[18] over there'
|
||||
html = ws.render_wolf_text_html(text, default_font_px=18)
|
||||
self.assertNotIn(r"\c[", html)
|
||||
self.assertNotIn(r"\f[", html)
|
||||
self.assertIn("cafe", html)
|
||||
self.assertIn("font-size:20px", html)
|
||||
self.assertIn("font-size:18px", html)
|
||||
# Emphasis colour for \\c[21]
|
||||
self.assertIn(ws.wolf_preview_color(21), html)
|
||||
|
||||
def test_format_wrap_preview_html_includes_gutters(self):
|
||||
text = (
|
||||
r'\f[18]"That \c[21]\f[20]cafe\c[19]\f[18] over there has been'
|
||||
'\npacked lately."'
|
||||
)
|
||||
html = ws.format_wrap_preview_html(text, 40)
|
||||
self.assertIn("(37)", html)
|
||||
self.assertIn("cafe", html)
|
||||
self.assertNotIn(r"\f[18]", html)
|
||||
self.assertIn("font-size:20px", html)
|
||||
self.assertIn(ws.wolf_preview_color(21), html)
|
||||
|
||||
def test_format_wrap_preview_html_simulates_body_font(self):
|
||||
text = r'That \c[21]\f[20]cafe\c[19]\f[18] over there'
|
||||
html = ws.format_wrap_preview_html(text, 40, body_font=13)
|
||||
# 20*13/18 ≈ 14; body 13
|
||||
self.assertIn("font-size:14px", html)
|
||||
self.assertIn("font-size:13px", html)
|
||||
self.assertNotIn("font-size:20px", html)
|
||||
self.assertNotIn("font-size:18px", html)
|
||||
|
||||
|
||||
class TestNamesCategoryWrap(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.translated = Path(self.tmp.name) / "translated"
|
||||
self.translated.mkdir()
|
||||
self.category = "├■街の噂(MOB)"
|
||||
fixture = {
|
||||
"kind": "names",
|
||||
"count": 2,
|
||||
"names": [
|
||||
{
|
||||
"source": "短い",
|
||||
"text": "Short line.",
|
||||
"note": self.category,
|
||||
},
|
||||
{
|
||||
"source": "長い",
|
||||
"text": (
|
||||
"This is a very long English rumor that should definitely "
|
||||
"wrap when the width is set to thirty-four characters."
|
||||
),
|
||||
"note": self.category,
|
||||
},
|
||||
],
|
||||
}
|
||||
self.path = self.translated / "names.json"
|
||||
self.path.write_text(json.dumps(fixture, ensure_ascii=False, indent=4), encoding="utf-8")
|
||||
|
||||
def tearDown(self):
|
||||
self.tmp.cleanup()
|
||||
|
||||
def test_names_categories_in_sheet_summaries(self):
|
||||
summaries = ws.sheet_overflow_summaries(self.translated, width=34)
|
||||
names_rows = [s for s in summaries if s.kind == "names" and s.sheet_name == self.category]
|
||||
self.assertEqual(len(names_rows), 1)
|
||||
self.assertEqual(names_rows[0].line_count, 2)
|
||||
self.assertEqual(names_rows[0].overflow_count, 1)
|
||||
|
||||
def test_wrap_all_in_names_category(self):
|
||||
doc = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
changed = ws.wrap_overflow_in_sheet(
|
||||
self.path, doc, self.category, 34, kind="names",
|
||||
)
|
||||
self.assertEqual(changed, 1)
|
||||
doc2 = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
long_entry = doc2["names"][1]["text"]
|
||||
self.assertIn("\n", long_entry)
|
||||
|
||||
def test_names_search_summary_shows_translated_text(self):
|
||||
hits = ws.search_translated_text("maids", self.translated)
|
||||
# Seed a maid line so EN query matches EN text, not JP source.
|
||||
doc = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
doc["names"].append(
|
||||
{
|
||||
"source": "「メイドがどんどん辞めていく」",
|
||||
"text": '"Yeah, maids keep quitting one after another."',
|
||||
"note": self.category,
|
||||
}
|
||||
)
|
||||
self.path.write_text(json.dumps(doc, ensure_ascii=False, indent=4), encoding="utf-8")
|
||||
hits = ws.search_translated_text("maids", self.translated)
|
||||
self.assertEqual(len(hits), 1)
|
||||
summary = hits[0].summary(40)
|
||||
self.assertIn("maids", summary)
|
||||
self.assertNotIn("メイド", summary)
|
||||
self.assertIn("entry #2", summary)
|
||||
# Translated preview is the first line of the summary.
|
||||
self.assertIn("\n", summary)
|
||||
self.assertTrue(summary.split("\n", 1)[0].strip().startswith('"Yeah'))
|
||||
|
||||
|
||||
class TestEventScopeWrap(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.translated = Path(self.tmp.name) / "translated"
|
||||
self.translated.mkdir()
|
||||
self.path = self.translated / "Map001.mps.json"
|
||||
self.path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"kind": "map",
|
||||
"file": "Map001.mps",
|
||||
"scenes": [
|
||||
{
|
||||
"event": 1,
|
||||
"lines": [
|
||||
{
|
||||
"text": (
|
||||
"This is a very long line of dialogue that "
|
||||
"should wrap at thirty four visible chars."
|
||||
),
|
||||
},
|
||||
{"text": "Short line."},
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=4,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
self.hit = ws.WrapHit(
|
||||
json_file="Map001.mps.json",
|
||||
kind="map",
|
||||
sheet_name="Map001.mps",
|
||||
row=1,
|
||||
field_name="scene 0 line 0",
|
||||
text="very long line",
|
||||
max_line_len=80,
|
||||
scene_index=0,
|
||||
line_index=0,
|
||||
map_file="Map001.mps",
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
self.tmp.cleanup()
|
||||
|
||||
def test_scope_stats_for_map_file(self):
|
||||
doc = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
stats = ws.scope_stats(doc, self.hit, 34)
|
||||
self.assertEqual(stats.total, 2)
|
||||
self.assertEqual(stats.overflow, 1)
|
||||
|
||||
def test_wrap_all_in_map_file(self):
|
||||
doc = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
changed = ws.wrap_overflow_in_scope(self.path, doc, self.hit, 34)
|
||||
self.assertEqual(changed, 1)
|
||||
|
||||
|
||||
class TestManualFontAndWrap(unittest.TestCase):
|
||||
def test_scales_emphasis_then_wraps(self):
|
||||
text = (
|
||||
r"So they even take worries like this in the "
|
||||
r"\c[21]\f[20]confessional\c[19]\f[18] booth every day."
|
||||
)
|
||||
new_text, changed = ws.apply_manual_font_and_wrap(text, 40, font=14)
|
||||
self.assertTrue(changed)
|
||||
self.assertIn(r"\f[16]", new_text)
|
||||
self.assertIn(r"\f[14]", new_text)
|
||||
self.assertLessEqual(dazedwrap.max_line_visible_length(new_text), 40)
|
||||
|
||||
def test_group_manual_skips_short_plain_lines(self):
|
||||
text = "Short."
|
||||
new_text, changed = ws.apply_manual_font_and_wrap(
|
||||
text, 40, font=14, only_overflow_or_fonts=True
|
||||
)
|
||||
self.assertFalse(changed)
|
||||
self.assertEqual(new_text, text)
|
||||
|
||||
def test_wrap_overflow_manual_in_names_category(self):
|
||||
tmp = tempfile.TemporaryDirectory()
|
||||
try:
|
||||
path = Path(tmp.name) / "names.json"
|
||||
doc = {
|
||||
"kind": "names",
|
||||
"names": [
|
||||
{
|
||||
"note": "噂",
|
||||
"source": "a",
|
||||
"text": (
|
||||
r"So they even take worries like this in the "
|
||||
r"\c[21]\f[20]confessional\c[19]\f[18] booth."
|
||||
),
|
||||
},
|
||||
{"note": "噂", "source": "b", "text": "OK"},
|
||||
{"note": "other", "source": "c", "text": "x" * 80},
|
||||
],
|
||||
}
|
||||
path.write_text(json.dumps(doc, ensure_ascii=False), encoding="utf-8")
|
||||
hit = ws.WrapHit(
|
||||
json_file="names.json",
|
||||
kind="names",
|
||||
sheet_name="噂",
|
||||
row=0,
|
||||
field_name="name",
|
||||
text=doc["names"][0]["text"],
|
||||
max_line_len=80,
|
||||
)
|
||||
changed = ws.wrap_overflow_manual_in_scope(path, doc, hit, 36, font=14)
|
||||
self.assertEqual(changed, 1)
|
||||
saved = json.loads(path.read_text(encoding="utf-8"))
|
||||
out = saved["names"][0]["text"]
|
||||
self.assertIn(r"\f[14]", out)
|
||||
self.assertEqual(saved["names"][1]["text"], "OK")
|
||||
self.assertEqual(saved["names"][2]["text"], "x" * 80)
|
||||
finally:
|
||||
tmp.cleanup()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -173,15 +173,15 @@ class TestTranslationWriteback(unittest.TestCase):
|
|||
self.assertIsNone(err)
|
||||
self.assertEqual(data["lines"][0]["text"], "EN_ゲームタイトル")
|
||||
|
||||
def test_names_translate_only_safe_and_refs(self):
|
||||
def test_names_translate_only_safe(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"][1]["text"], "EN_槍")
|
||||
self.assertEqual(data["names"][1]["text"], "槍")
|
||||
self.assertEqual(data["names"][2]["text"], "スイッチ状態")
|
||||
self.assertEqual(captured, [["剣", "槍"]])
|
||||
self.assertEqual(captured, [["剣"]])
|
||||
|
||||
def test_names_without_safety_badges_translate_nothing(self):
|
||||
(data, _t, err), captured = _WolfTranslateHarness().run(
|
||||
|
|
@ -198,7 +198,7 @@ class TestTranslationWriteback(unittest.TestCase):
|
|||
self.assertEqual(
|
||||
harness.vocab_writes,
|
||||
[
|
||||
("Weapon · 武器", [("剣", "EN_剣"), ("槍", "EN_槍")]),
|
||||
("Weapon · 武器", [("剣", "EN_剣")]),
|
||||
],
|
||||
)
|
||||
|
||||
|
|
@ -451,7 +451,7 @@ class TestTranslationWriteback(unittest.TestCase):
|
|||
self.assertEqual(captured, [])
|
||||
self.assertEqual(
|
||||
harness.vocab_writes,
|
||||
[("Weapon · 武器", [("剣", "Sword"), ("槍", "Spear")])],
|
||||
[("Weapon · 武器", [("剣", "Sword")])],
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -493,10 +493,8 @@ SPEAKER_MAP_DOC = {
|
|||
class _SpeakerHarness:
|
||||
"""Run parseDocument with the speaker-aware mock and a chosen speaker config."""
|
||||
|
||||
def __init__(self, config, wrap=False, width=0):
|
||||
def __init__(self, config):
|
||||
self.config = config
|
||||
self.wrap = wrap
|
||||
self.width = width
|
||||
self.captured = []
|
||||
|
||||
def run(self, data, filename="OP.mps.json"):
|
||||
|
|
@ -504,17 +502,15 @@ class _SpeakerHarness:
|
|||
self.captured.append(copy.deepcopy(text))
|
||||
return _mock_translate_speaker(text, history, history_ctx)
|
||||
|
||||
orig = (wd.translateAI, wd.ESTIMATE, wd.SPEAKER_CONFIG, wd.WRAP, wd.WRAPWIDTH)
|
||||
orig = (wd.translateAI, wd.ESTIMATE, wd.SPEAKER_CONFIG)
|
||||
wd.translateAI = translate
|
||||
wd.ESTIMATE = False
|
||||
wd.SPEAKER_CONFIG = self.config
|
||||
wd.WRAP = self.wrap
|
||||
wd.WRAPWIDTH = self.width
|
||||
try:
|
||||
result = wd.parseDocument(copy.deepcopy(data), filename)
|
||||
return result, self.captured
|
||||
finally:
|
||||
(wd.translateAI, wd.ESTIMATE, wd.SPEAKER_CONFIG, wd.WRAP, wd.WRAPWIDTH) = orig
|
||||
(wd.translateAI, wd.ESTIMATE, wd.SPEAKER_CONFIG) = orig
|
||||
|
||||
|
||||
class TestSpeakerReshaping(unittest.TestCase):
|
||||
|
|
@ -549,132 +545,6 @@ class TestSpeakerReshaping(unittest.TestCase):
|
|||
self.assertEqual(lines[0]["text"], "EN_市民\nおはよう\n元気?")
|
||||
|
||||
|
||||
class TestWrapping(unittest.TestCase):
|
||||
"""dazedwrap-style re-wrapping, with the speaker name kept on its own line."""
|
||||
|
||||
def setUp(self):
|
||||
self._orig = (wd.WRAP, wd.WRAPWIDTH)
|
||||
|
||||
def tearDown(self):
|
||||
wd.WRAP, wd.WRAPWIDTH = self._orig
|
||||
|
||||
def _set(self, enabled, width):
|
||||
wd.WRAP, wd.WRAPWIDTH = enabled, width
|
||||
|
||||
def test_wrap_body_respects_width_and_keeps_words(self):
|
||||
self._set(True, 10)
|
||||
out = wd._wrap_body("alpha beta gamma delta")
|
||||
self.assertGreater(out.count("\n"), 0) # actually wrapped
|
||||
for line in out.split("\n"):
|
||||
self.assertLessEqual(len(line), 10)
|
||||
self.assertEqual(out.replace("\n", " "), "alpha beta gamma delta")
|
||||
|
||||
def test_wrap_disabled_is_noop(self):
|
||||
self._set(False, 10)
|
||||
self.assertEqual(wd._wrap_body("alpha beta gamma delta"), "alpha beta gamma delta")
|
||||
|
||||
def test_zero_width_is_noop(self):
|
||||
self._set(True, 0)
|
||||
self.assertEqual(wd._wrap_body("alpha beta gamma delta"), "alpha beta gamma delta")
|
||||
|
||||
def test_wrap_plain_preserves_nameplate_line(self):
|
||||
self._set(True, 12)
|
||||
out = wd._wrap_plain("Name\nalpha beta gamma delta epsilon", is_firstline=True)
|
||||
self.assertEqual(out.split("\n", 1)[0], "Name") # name never merged into body
|
||||
for line in out.split("\n")[1:]:
|
||||
self.assertLessEqual(len(line), 12)
|
||||
|
||||
def test_wrap_plain_preserves_window_prefix(self):
|
||||
self._set(True, 12)
|
||||
out = wd._wrap_plain("@1\nName\nalpha beta gamma delta", is_firstline=True)
|
||||
self.assertTrue(out.startswith("@1\nName\n"))
|
||||
|
||||
def test_speaker_body_wrapped_but_name_kept(self):
|
||||
# Integration: even at a tiny width the (translated) name stays on line 1.
|
||||
cfg = {"literal_line1": True, "literal_line1_lowconf": True}
|
||||
(data, _t, err), _c = _SpeakerHarness(cfg, wrap=True, width=6).run(SPEAKER_MAP_DOC)
|
||||
self.assertIsNone(err)
|
||||
line0 = data["scenes"][0]["lines"][0]["text"]
|
||||
self.assertEqual(line0.split("\n", 1)[0], "EN_市民")
|
||||
|
||||
|
||||
class TestNameCategoryWrap(unittest.TestCase):
|
||||
"""dazedwrap for selected names.json note categories (Step 3)."""
|
||||
|
||||
def setUp(self):
|
||||
self._orig = (wd.NAME_WRAP_ENABLED, wd.NAME_WRAP_WIDTH, wd.NAME_WRAP_NOTES)
|
||||
|
||||
def tearDown(self):
|
||||
wd.NAME_WRAP_ENABLED, wd.NAME_WRAP_WIDTH, wd.NAME_WRAP_NOTES = self._orig
|
||||
|
||||
def _set(self, enabled, width, notes):
|
||||
wd.NAME_WRAP_ENABLED = enabled
|
||||
wd.NAME_WRAP_WIDTH = width
|
||||
wd.NAME_WRAP_NOTES = frozenset(notes)
|
||||
|
||||
def test_wrap_names_entry_only_selected_notes(self):
|
||||
self._set(True, 10, {"├■プロフィール"})
|
||||
long_text = "alpha beta gamma delta epsilon"
|
||||
entry = {"note": "├■プロフィール"}
|
||||
wrapped = wd._wrap_names_entry(long_text, entry)
|
||||
self.assertGreater(wrapped.count("\n"), 0)
|
||||
entry_other = {"note": "武器"}
|
||||
self.assertEqual(wd._wrap_names_entry(long_text, entry_other), long_text)
|
||||
|
||||
def test_wrap_names_entry_disabled_is_noop(self):
|
||||
self._set(False, 10, {"├■プロフィール"})
|
||||
long_text = "alpha beta gamma delta epsilon"
|
||||
entry = {"note": "├■プロフィール"}
|
||||
self.assertEqual(wd._wrap_names_entry(long_text, entry), long_text)
|
||||
|
||||
def test_names_translation_wraps_selected_category(self):
|
||||
# Narrow JP source (8 cells of CJK) so LANGREGEX matches; mock returns long EN.
|
||||
profile_src = "あいうえ" # 4 CJK → matches LANGREGEX
|
||||
doc = {
|
||||
"kind": "names",
|
||||
"names": [
|
||||
{"source": "剣", "text": "剣", "note": "武器", "safety": "safe"},
|
||||
{
|
||||
"source": profile_src,
|
||||
"text": profile_src,
|
||||
"note": "├■プロフィール",
|
||||
"safety": "safe",
|
||||
},
|
||||
],
|
||||
}
|
||||
self._set(True, 8, {"├■プロフィール"})
|
||||
long_en = "alpha beta gamma delta epsilon zeta eta theta"
|
||||
orig_t = wd.translateAI
|
||||
orig_estimate = wd.ESTIMATE
|
||||
orig_wrap = wd.WRAP
|
||||
orig_update = wd.wolf_vocab.update_vocab_section
|
||||
orig_labels = wd.wolf_names.derive_db_labels
|
||||
|
||||
def translate(text, history, history_ctx=None):
|
||||
out = []
|
||||
for t in text:
|
||||
out.append(long_en if t == profile_src else f"EN_{t}")
|
||||
return [out, [1, 1]]
|
||||
|
||||
wd.translateAI = translate
|
||||
wd.ESTIMATE = False
|
||||
wd.WRAP = False
|
||||
wd.wolf_vocab.update_vocab_section = lambda *_a, **_k: None
|
||||
wd.wolf_names.derive_db_labels = lambda _p: {}
|
||||
try:
|
||||
data, _t, err = wd.parseDocument(copy.deepcopy(doc), "names.json")
|
||||
finally:
|
||||
wd.translateAI = orig_t
|
||||
wd.ESTIMATE = orig_estimate
|
||||
wd.WRAP = orig_wrap
|
||||
wd.wolf_vocab.update_vocab_section = orig_update
|
||||
wd.wolf_names.derive_db_labels = orig_labels
|
||||
self.assertIsNone(err)
|
||||
names = {n["note"]: n["text"] for n in data["names"]}
|
||||
self.assertEqual(names["武器"], "EN_剣")
|
||||
self.assertGreater(names["├■プロフィール"].count("\n"), 0)
|
||||
|
||||
|
||||
class TestOpenFiles(unittest.TestCase):
|
||||
def test_rejects_unknown_kind(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
|
|
|
|||
|
|
@ -30,7 +30,8 @@ class TestRelayoutWrappers(unittest.TestCase):
|
|||
self.assertIn("--width", captured["args"])
|
||||
self.assertIn("55", captured["args"])
|
||||
self.assertNotIn("-o", captured["args"])
|
||||
self.assertNotIn("--max-rows", captured["args"]) # 0 omitted
|
||||
self.assertIn("--max-rows", captured["args"])
|
||||
self.assertIn("auto", captured["args"])
|
||||
|
||||
def test_relayout_write_args(self):
|
||||
captured = {}
|
||||
|
|
@ -79,9 +80,105 @@ class TestRelayoutWrappers(unittest.TestCase):
|
|||
self.assertIn("--width", args)
|
||||
self.assertIn("75", args)
|
||||
self.assertIn("--max-lines", args)
|
||||
self.assertIn("0", args)
|
||||
self.assertIn("auto", args)
|
||||
self.assertIn("--keep-breaks", args)
|
||||
|
||||
def test_relayout_auto_width(self):
|
||||
captured = {}
|
||||
|
||||
def fake_run(args, log_fn=None):
|
||||
captured["args"] = list(args)
|
||||
return wolfdawn.WolfResult(0, "", "", [str(a) for a in args])
|
||||
|
||||
with patch.object(wolfdawn, "_run", side_effect=fake_run):
|
||||
wolfdawn.relayout("/game/Data", width="auto", max_rows="auto")
|
||||
args = captured["args"]
|
||||
self.assertIn("--width", args)
|
||||
self.assertIn("auto", args)
|
||||
self.assertIn("--max-rows", args)
|
||||
|
||||
def test_names_wrap_args(self):
|
||||
captured = {}
|
||||
|
||||
def fake_run(args, log_fn=None):
|
||||
captured["args"] = list(args)
|
||||
return wolfdawn.WolfResult(
|
||||
0,
|
||||
"names-wrap: 3 entry(ies) re-wrapped",
|
||||
"",
|
||||
[str(a) for a in args],
|
||||
)
|
||||
|
||||
with patch.object(wolfdawn, "_run", side_effect=fake_run):
|
||||
res = wolfdawn.names_wrap("/tmp/names.json")
|
||||
self.assertEqual(captured["args"], ["names-wrap", "/tmp/names.json"])
|
||||
self.assertEqual(wolfdawn.parse_names_wrap_counts(res.stdout), 3)
|
||||
|
||||
def test_names_wrap_dry_run(self):
|
||||
captured = {}
|
||||
|
||||
def fake_run(args, log_fn=None):
|
||||
captured["args"] = list(args)
|
||||
return wolfdawn.WolfResult(
|
||||
0,
|
||||
"names-wrap: 2 entry(ies) WOULD be re-wrapped",
|
||||
"",
|
||||
[str(a) for a in args],
|
||||
)
|
||||
|
||||
with patch.object(wolfdawn, "_run", side_effect=fake_run):
|
||||
wolfdawn.names_wrap("/tmp/names.json", dry_run=True)
|
||||
self.assertEqual(captured["args"], ["names-wrap", "/tmp/names.json", "--dry-run"])
|
||||
|
||||
def test_names_wrap_note_and_width(self):
|
||||
captured = {}
|
||||
|
||||
def fake_run(args, log_fn=None):
|
||||
captured["args"] = list(args)
|
||||
return wolfdawn.WolfResult(0, "", "", [str(a) for a in args])
|
||||
|
||||
with patch.object(wolfdawn, "_run", side_effect=fake_run):
|
||||
wolfdawn.names_wrap(
|
||||
"/tmp/names.json",
|
||||
note="├■街の噂(MOB)",
|
||||
width=66,
|
||||
)
|
||||
self.assertEqual(
|
||||
captured["args"],
|
||||
["names-wrap", "/tmp/names.json", "--width", "66", "--note", "├■街の噂(MOB)"],
|
||||
)
|
||||
|
||||
def test_names_wrap_lines(self):
|
||||
captured = {}
|
||||
|
||||
def fake_run(args, log_fn=None):
|
||||
captured["args"] = list(args)
|
||||
return wolfdawn.WolfResult(0, "", "", [str(a) for a in args])
|
||||
|
||||
with patch.object(wolfdawn, "_run", side_effect=fake_run):
|
||||
wolfdawn.names_wrap("/tmp/names.json", width=50, lines=3, note="説明")
|
||||
self.assertEqual(
|
||||
captured["args"],
|
||||
[
|
||||
"names-wrap",
|
||||
"/tmp/names.json",
|
||||
"--width",
|
||||
"50",
|
||||
"--lines",
|
||||
"3",
|
||||
"--note",
|
||||
"説明",
|
||||
],
|
||||
)
|
||||
|
||||
def test_parse_names_wrap_shrink_count(self):
|
||||
blob = (
|
||||
' shrunk to \\f[14] to fit its slot: "Hey! ..."\n'
|
||||
' shrunk to \\f[12] to fit its slot: "That sister..."\n'
|
||||
"names-wrap: 7 entry(ies) re-wrapped"
|
||||
)
|
||||
self.assertEqual(wolfdawn.parse_names_wrap_shrink_count(blob), 2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
|
|
|
|||
|
|
@ -1,20 +1,83 @@
|
|||
import re
|
||||
|
||||
# Wolf / RPG Maker inline codes that do not occupy visible box width.
|
||||
_WOLF_INLINE_VISIBLE_STRIP = re.compile(
|
||||
r"\\(?:"
|
||||
r"r\[[^\]]*\]|"
|
||||
r"c(?:self)?\[[^\]]*\]|"
|
||||
r"f\[[^\]]*\]|"
|
||||
r"[A-Za-z]+\[[^\]]*\]|"
|
||||
r"\^|"
|
||||
r"[ @<>\-]|"
|
||||
r"[A-Za-z]"
|
||||
r")"
|
||||
)
|
||||
_WOLF_CODE_PLACEHOLDER = re.compile(r"__WOLF_CODE_\d+__")
|
||||
|
||||
|
||||
def _get_visible_length(text: str) -> int:
|
||||
"""
|
||||
Calculate the visible length of text, ignoring RPG Maker color codes.
|
||||
|
||||
Args:
|
||||
text (str): The text to measure
|
||||
|
||||
Returns:
|
||||
int: The length of the text excluding color codes
|
||||
Calculate the visible length of text, ignoring inline control codes.
|
||||
|
||||
Handles Wolf ``\\f[N]``, ``\\c[N]``, ruby ``\\r[...]``, and legacy RPG Maker
|
||||
color codes so wrap-width checks match in-game bulletin / message boxes.
|
||||
"""
|
||||
# Remove all color codes like \c[5] or \C[35], and also ignore \\! and \\.
|
||||
cleaned_text = re.sub(r'[\\]+[cC]\[\d+\]', '', text)
|
||||
# Remove \\! and \\.
|
||||
cleaned_text = re.sub(r'[\\]+.', '', cleaned_text)
|
||||
return len(cleaned_text)
|
||||
if not text:
|
||||
return 0
|
||||
cleaned = _WOLF_INLINE_VISIBLE_STRIP.sub("", text)
|
||||
cleaned = _WOLF_CODE_PLACEHOLDER.sub("", cleaned)
|
||||
cleaned = re.sub(r"[\\]+[cC]\[\d+\]", "", cleaned)
|
||||
cleaned = re.sub(r"[\\]+[fF](?:\[\d+\])?", "", cleaned)
|
||||
cleaned = re.sub(r"[\\]+.", "", cleaned)
|
||||
cleaned = re.sub(r"\s+", " ", cleaned).strip()
|
||||
return len(cleaned)
|
||||
|
||||
|
||||
def visible_word_wrap_end_index(text: str, width: int) -> int:
|
||||
"""Return index into *text* after the last word that fits *width* visible chars.
|
||||
|
||||
Uses the same word-wrapping rules as :func:`wrapText`. Wolf ``\\c[]`` /
|
||||
``\\f[]`` codes do not count toward *width*.
|
||||
"""
|
||||
if not text or width <= 0:
|
||||
return len(text)
|
||||
if _get_visible_length(text) <= width:
|
||||
return len(text)
|
||||
words = text.split()
|
||||
if not words:
|
||||
return len(text)
|
||||
current_len = 0
|
||||
kept = 0
|
||||
pos = 0
|
||||
fit_end = 0
|
||||
for word in words:
|
||||
wl = _get_visible_length(word)
|
||||
gap = 1 if kept > 0 else 0
|
||||
if current_len + gap + wl <= width:
|
||||
idx = text.find(word, pos)
|
||||
if idx < 0:
|
||||
break
|
||||
pos = idx + len(word)
|
||||
fit_end = pos
|
||||
kept += 1
|
||||
current_len += gap + wl
|
||||
else:
|
||||
break
|
||||
if kept == 0 and words:
|
||||
idx = text.find(words[0], 0)
|
||||
fit_end = (idx + len(words[0])) if idx >= 0 else len(text)
|
||||
return fit_end
|
||||
|
||||
|
||||
def max_line_visible_length(text: str) -> int:
|
||||
"""Longest visible line length in *text* (handles ``\\r`` / ``\\n``)."""
|
||||
if not isinstance(text, str) or not text:
|
||||
return 0
|
||||
normalized = text.replace("\r\n", "\n").replace("\r", "\n")
|
||||
lines = normalized.split("\n") if normalized else [""]
|
||||
if not lines:
|
||||
return _get_visible_length(normalized)
|
||||
return max(_get_visible_length(line) for line in lines)
|
||||
|
||||
def wrapText(text: str, width: int) -> str:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -49,6 +49,9 @@ __all__ = [
|
|||
"pack",
|
||||
"save_update",
|
||||
"names_check",
|
||||
"names_wrap",
|
||||
"parse_names_wrap_counts",
|
||||
"parse_names_wrap_shrink_count",
|
||||
"relayout",
|
||||
"desc_relayout",
|
||||
]
|
||||
|
|
@ -69,7 +72,8 @@ _INJECT_MISMATCH_EVENT_RE = re.compile(
|
|||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_NAMES_INJECT_COUNTS_RE = re.compile(
|
||||
r"applied\s+(\d+)\s+name change.*?(\d+)\s+drifted", re.IGNORECASE | re.DOTALL
|
||||
r"(?:would apply|applied)\s+(\d+)\s+name change.*?(\d+)\s+(?:drifted|unmatched)",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
|
||||
ProgressFn = Callable[[int, int, str], None]
|
||||
|
|
@ -138,6 +142,18 @@ def inject_had_applied(applied: int | None) -> bool:
|
|||
"""
|
||||
return applied is not None and applied > 0
|
||||
|
||||
|
||||
def parse_inject_cli_error(stdout: str, stderr: str = "") -> str | None:
|
||||
"""Return the first wolf inject CLI error line, if any."""
|
||||
for line in _inject_output_text(stdout, stderr).splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
for prefix in ("strings-inject:", "names-inject:"):
|
||||
if prefix in line:
|
||||
return line.split(prefix, 1)[-1].strip()
|
||||
return None
|
||||
|
||||
# Committed, prebuilt binaries live here (per-platform) so end users don't need a
|
||||
# Rust toolchain or a live upstream fetch.
|
||||
_PACKAGE_DIR = Path(__file__).resolve().parent
|
||||
|
|
@ -165,6 +181,11 @@ _DOWNLOAD_UA = (
|
|||
PathLike = Union[str, Path]
|
||||
|
||||
|
||||
def db_dat_sibling(project_path: PathLike) -> Path:
|
||||
"""Sibling ``.dat`` for a WolfDawn ``*.project`` database pair."""
|
||||
return Path(project_path).with_suffix(".dat")
|
||||
|
||||
|
||||
class WolfDawnError(RuntimeError):
|
||||
"""Raised when the WolfDawn binary can't be located/downloaded or a command fails hard."""
|
||||
|
||||
|
|
@ -615,6 +636,7 @@ def names_inject(
|
|||
out_dir: Optional[PathLike] = None,
|
||||
allow_code_drift: bool = False,
|
||||
en_punct: bool = False,
|
||||
dry_run: bool = False,
|
||||
log_fn=None,
|
||||
) -> WolfResult:
|
||||
"""``wolf names-inject <names.json> --data <data-dir> [-o <out-dir>] [flags]``."""
|
||||
|
|
@ -625,6 +647,8 @@ def names_inject(
|
|||
args.append("--allow-code-drift")
|
||||
if en_punct:
|
||||
args.append("--en-punct")
|
||||
if dry_run:
|
||||
args.append("--dry-run")
|
||||
return _run(args, log_fn=log_fn)
|
||||
|
||||
|
||||
|
|
@ -634,6 +658,76 @@ def names_check(json_files: Iterable[PathLike], log_fn=None) -> WolfResult:
|
|||
return _run(args, log_fn=log_fn)
|
||||
|
||||
|
||||
_NAMES_WRAP_WOULD_RE = re.compile(
|
||||
r"(\d+)\s+entry\(ies\)\s+WOULD\s+be\s+re-wrapped", re.IGNORECASE
|
||||
)
|
||||
_NAMES_WRAP_APPLIED_RE = re.compile(
|
||||
r"(\d+)\s+entry\(ies\)\s+re-wrapped", re.IGNORECASE
|
||||
)
|
||||
_NAMES_WRAP_SHRUNK_RE = re.compile(r"shrunk to \\f\[(\d+)\]", re.IGNORECASE)
|
||||
|
||||
|
||||
def parse_names_wrap_counts(stdout: str, stderr: str = "") -> int | None:
|
||||
"""Return how many entries names-wrap would change or did change, or None."""
|
||||
blob = f"{stdout}\n{stderr}"
|
||||
for pattern in (_NAMES_WRAP_APPLIED_RE, _NAMES_WRAP_WOULD_RE):
|
||||
match = pattern.search(blob)
|
||||
if match:
|
||||
return int(match.group(1))
|
||||
return None
|
||||
|
||||
|
||||
def parse_names_wrap_shrink_count(stdout: str, stderr: str = "") -> int:
|
||||
"""Return how many entries names-wrap shrunk with a leading ``\\f[N]``."""
|
||||
blob = f"{stdout}\n{stderr}"
|
||||
return len(_NAMES_WRAP_SHRUNK_RE.findall(blob))
|
||||
|
||||
|
||||
def names_wrap(
|
||||
names_json: PathLike,
|
||||
*,
|
||||
width: int | None = None,
|
||||
lines: int | None = None,
|
||||
note: str | None = None,
|
||||
dry_run: bool = False,
|
||||
log_fn=None,
|
||||
) -> WolfResult:
|
||||
"""``wolf names-wrap <names.json> [--width N] [--lines N] [--note <category>] [--dry-run]``.
|
||||
|
||||
Re-wraps translated multi-line glossary entries to each category's slot
|
||||
geometry (JP-measured, ``wolfdawn-roles.json`` overrides, or auto-expanded).
|
||||
``lines`` overrides each entry's JP line budget (``2+`` also wraps one-line
|
||||
names). Entries that still overflow shrink via a leading ``\\f[N]``.
|
||||
"""
|
||||
args = ["names-wrap", _str(names_json)]
|
||||
if width is not None and int(width) > 0:
|
||||
args += ["--width", str(int(width))]
|
||||
if lines is not None and int(lines) > 0:
|
||||
args += ["--lines", str(int(lines))]
|
||||
if note:
|
||||
args += ["--note", str(note)]
|
||||
if dry_run:
|
||||
args.append("--dry-run")
|
||||
return _run(args, log_fn=log_fn)
|
||||
|
||||
|
||||
def _relayout_metric_arg(value: int | str | None, *, zero_is_auto: bool = False) -> str | None:
|
||||
"""Format one relayout geometry flag (``55``, ``auto``, or omit)."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
token = value.strip().lower()
|
||||
if not token or token == "auto":
|
||||
return "auto"
|
||||
try:
|
||||
value = int(token)
|
||||
except ValueError:
|
||||
return token
|
||||
if zero_is_auto and int(value) == 0:
|
||||
return "auto"
|
||||
return str(int(value))
|
||||
|
||||
|
||||
def pack(
|
||||
directory: PathLike,
|
||||
output: PathLike,
|
||||
|
|
@ -679,12 +773,12 @@ def relayout(
|
|||
data_dir: PathLike,
|
||||
out_dir: Optional[PathLike] = None,
|
||||
*,
|
||||
width: Optional[int] = None,
|
||||
width_face: Optional[int] = None,
|
||||
max_rows: Optional[int] = None,
|
||||
width: int | str | None = None,
|
||||
width_face: int | str | None = None,
|
||||
max_rows: int | str | None = None,
|
||||
sub_width: Optional[int] = None,
|
||||
base_font: Optional[int] = None,
|
||||
no_formup: bool = False,
|
||||
base_font: int | str | None = None,
|
||||
no_fixup: bool = False,
|
||||
no_resolve: bool = False,
|
||||
log_fn=None,
|
||||
) -> WolfResult:
|
||||
|
|
@ -692,22 +786,26 @@ def relayout(
|
|||
|
||||
Without ``-o`` this is a dry run. With ``-o``, only changed map / CommonEvent
|
||||
files are written under ``out_dir`` (paths relative to ``data_dir``).
|
||||
Pass ``width="auto"`` or ``max_rows=0`` to measure geometry from JP text.
|
||||
"""
|
||||
args: list[str] = ["relayout", _str(data_dir)]
|
||||
if out_dir is not None:
|
||||
args += ["-o", _str(out_dir)]
|
||||
if width is not None:
|
||||
args += ["--width", str(int(width))]
|
||||
if width_face is not None:
|
||||
args += ["--width-face", str(int(width_face))]
|
||||
if max_rows is not None and int(max_rows) > 0:
|
||||
args += ["--max-rows", str(int(max_rows))]
|
||||
width_arg = _relayout_metric_arg(width)
|
||||
if width_arg is not None:
|
||||
args += ["--width", width_arg]
|
||||
width_face_arg = _relayout_metric_arg(width_face)
|
||||
if width_face_arg is not None:
|
||||
args += ["--width-face", width_face_arg]
|
||||
if max_rows is not None:
|
||||
args += ["--max-rows", _relayout_metric_arg(max_rows, zero_is_auto=True) or "auto"]
|
||||
if sub_width is not None:
|
||||
args += ["--sub-width", str(int(sub_width))]
|
||||
if base_font is not None:
|
||||
args += ["--base-font", str(int(base_font))]
|
||||
if no_formup:
|
||||
args.append("--no-formup")
|
||||
base_arg = _relayout_metric_arg(base_font, zero_is_auto=True)
|
||||
args += ["--base-font", base_arg if base_arg is not None else "0"]
|
||||
if no_fixup:
|
||||
args.append("--no-fixup")
|
||||
if no_resolve:
|
||||
args.append("--no-resolve")
|
||||
return _run(args, log_fn=log_fn)
|
||||
|
|
@ -717,33 +815,36 @@ def desc_relayout(
|
|||
project: PathLike,
|
||||
output: PathLike,
|
||||
*,
|
||||
width: Optional[int] = None,
|
||||
max_lines: Optional[int] = None,
|
||||
font: Optional[int] = None,
|
||||
width: int | str | None = None,
|
||||
max_lines: int | str | None = None,
|
||||
font: int | str | None = None,
|
||||
min_font: Optional[int] = None,
|
||||
types: Optional[str] = None,
|
||||
keep_breaks: bool = False,
|
||||
no_formup: bool = False,
|
||||
no_fixup: bool = False,
|
||||
log_fn=None,
|
||||
) -> WolfResult:
|
||||
"""``wolf desc-relayout <X.project> -o <out.project> [flags]``.
|
||||
|
||||
``width`` may be omitted when ``wolfdawn-roles.json`` defines ``descBoxes``.
|
||||
``width`` may be omitted or set to ``auto`` to measure from JP text.
|
||||
``max_lines=0`` means auto (read each field's ``[N行]`` hint).
|
||||
"""
|
||||
args: list[str] = ["desc-relayout", _str(project), "-o", _str(output)]
|
||||
if width is not None:
|
||||
args += ["--width", str(int(width))]
|
||||
width_arg = _relayout_metric_arg(width)
|
||||
if width_arg is not None:
|
||||
args += ["--width", width_arg]
|
||||
if max_lines is not None:
|
||||
args += ["--max-lines", str(int(max_lines))]
|
||||
max_arg = _relayout_metric_arg(max_lines, zero_is_auto=True)
|
||||
args += ["--max-lines", max_arg if max_arg is not None else "0"]
|
||||
if font is not None:
|
||||
args += ["--font", str(int(font))]
|
||||
font_arg = _relayout_metric_arg(font, zero_is_auto=True)
|
||||
args += ["--font", font_arg if font_arg is not None else "0"]
|
||||
if min_font is not None:
|
||||
args += ["--min-font", str(int(min_font))]
|
||||
if types:
|
||||
args += ["--types", str(types)]
|
||||
if keep_breaks:
|
||||
args.append("--keep-breaks")
|
||||
if no_formup:
|
||||
args.append("--no-formup")
|
||||
if no_fixup:
|
||||
args.append("--no-fixup")
|
||||
return _run(args, log_fn=log_fn)
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
|
|
@ -29,6 +29,188 @@ WOLF_INLINE_CODE_RE = re.compile(
|
|||
# Placeholder transport (same convention as util.translation.protect_script_codes).
|
||||
_PLACEHOLDER_PREFIX = "__WOLF_CODE_"
|
||||
|
||||
# Wolf font-size codes: ``\f[18]``, ``\f[20]``, etc.
|
||||
WOLF_FONT_SIZE_RE = re.compile(r"\\f\[(\d+)\]")
|
||||
|
||||
|
||||
def detect_font_sizes(text: str) -> list[int]:
|
||||
"""Return sorted unique ``\\f[N]`` sizes in *text*."""
|
||||
if not isinstance(text, str) or not text:
|
||||
return []
|
||||
sizes: set[int] = set()
|
||||
for match in WOLF_FONT_SIZE_RE.finditer(text):
|
||||
try:
|
||||
sizes.add(int(match.group(1)))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return sorted(sizes)
|
||||
|
||||
|
||||
def infer_base_font_size(text: str, *, default: int = 18) -> int:
|
||||
"""Guess the body / \"normal\" ``\\f[N]`` size for *text*.
|
||||
|
||||
Emphasis overrides (e.g. ``\\f[20]…\\f[18]``) keep a larger size for a span
|
||||
then restore the body size. Prefer the most common size; on a tie, the
|
||||
smaller one (body over emphasis).
|
||||
"""
|
||||
if not isinstance(text, str) or not text:
|
||||
return int(default)
|
||||
counts: dict[int, int] = {}
|
||||
for match in WOLF_FONT_SIZE_RE.finditer(text):
|
||||
try:
|
||||
size = int(match.group(1))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
counts[size] = counts.get(size, 0) + 1
|
||||
if not counts:
|
||||
return int(default)
|
||||
top = max(counts.values())
|
||||
return min(size for size, n in counts.items() if n == top)
|
||||
|
||||
|
||||
def ensure_leading_font_size(text: str, size: int) -> str:
|
||||
"""Ensure *text* begins with ``\\f[size]`` so Wolf sets line height from the start.
|
||||
|
||||
Mid-line ``\\f[N]`` alone (e.g. ``"That \\c[21]\\f[14]cafe…``) leaves the
|
||||
opening glyphs on the default face and mis-aligns the row. WolfDawn
|
||||
``names-wrap`` always leads with the body size for the same reason.
|
||||
"""
|
||||
if not isinstance(text, str) or not text or int(size) <= 0:
|
||||
return text
|
||||
size = int(size)
|
||||
lead = rf"\f[{size}]"
|
||||
# Already starts with the desired body size.
|
||||
m = WOLF_FONT_SIZE_RE.match(text)
|
||||
if m and int(m.group(1)) == size:
|
||||
return text
|
||||
# Replace a different leading \\f[N] with the body size.
|
||||
if m:
|
||||
return lead + text[m.end() :]
|
||||
return lead + text
|
||||
|
||||
|
||||
def scale_font_sizes(
|
||||
text: str,
|
||||
new_base: int,
|
||||
*,
|
||||
old_base: int | None = None,
|
||||
min_size: int = 8,
|
||||
) -> tuple[str, int]:
|
||||
"""Scale every ``\\f[N]`` proportionally when the body font changes.
|
||||
|
||||
Example: body ``\\f[18]`` with emphasis ``\\f[20]`` shrunk to body 14 becomes
|
||||
``\\f[16]`` / ``\\f[14]`` (``round(N * new_base / old_base)``, floored at
|
||||
*min_size*). Always ensures a leading ``\\f[new_base]`` so Wolf measures the
|
||||
line from the body size (fixes mid-line emphasis like
|
||||
``"That \\c[21]\\f[14]cafe\\c[19]\\f[13]…``).
|
||||
|
||||
When *text* has no ``\\f[N]``, prepends ``\\f[new_base]``.
|
||||
Returns ``(new_text, replacements)``.
|
||||
"""
|
||||
if not isinstance(text, str) or not text.strip() or int(new_base) <= 0:
|
||||
return text, 0
|
||||
new_base = int(new_base)
|
||||
min_size = max(1, int(min_size))
|
||||
matches = list(WOLF_FONT_SIZE_RE.finditer(text))
|
||||
if not matches:
|
||||
return ensure_leading_font_size(text, new_base), 1
|
||||
|
||||
base = int(old_base) if old_base is not None and int(old_base) > 0 else infer_base_font_size(text)
|
||||
if base <= 0:
|
||||
base = new_base
|
||||
|
||||
def _scale(match: re.Match) -> str:
|
||||
try:
|
||||
old = int(match.group(1))
|
||||
except (TypeError, ValueError):
|
||||
return match.group(0)
|
||||
scaled = max(min_size, int(round(old * new_base / base)))
|
||||
return rf"\f[{scaled}]"
|
||||
|
||||
had_leading = bool(WOLF_FONT_SIZE_RE.match(text))
|
||||
new_text = WOLF_FONT_SIZE_RE.sub(_scale, text)
|
||||
new_text = ensure_leading_font_size(new_text, new_base)
|
||||
count = len(matches) + (0 if had_leading else 1)
|
||||
return new_text, count
|
||||
|
||||
|
||||
def apply_font_size(text: str, size: int) -> tuple[str, int]:
|
||||
"""Force every ``\\f[N]`` in *text* to the same *size* (no proportional scale).
|
||||
|
||||
Prefer :func:`scale_font_sizes` when the line has emphasis overrides that
|
||||
should stay relatively larger than the body font.
|
||||
|
||||
Always ensures a leading ``\\f[size]``. Returns ``(new_text, replacements)``.
|
||||
"""
|
||||
if not isinstance(text, str) or not text.strip() or size <= 0:
|
||||
return text, 0
|
||||
size = int(size)
|
||||
matches = list(WOLF_FONT_SIZE_RE.finditer(text))
|
||||
if matches:
|
||||
had_leading = bool(WOLF_FONT_SIZE_RE.match(text))
|
||||
new_text = WOLF_FONT_SIZE_RE.sub(rf"\\f[{size}]", text)
|
||||
new_text = ensure_leading_font_size(new_text, size)
|
||||
count = len(matches) + (0 if had_leading else 1)
|
||||
return new_text, count
|
||||
return ensure_leading_font_size(text, size), 1
|
||||
|
||||
|
||||
def _control_code_tokens(text: str) -> list[str]:
|
||||
"""Return the sequence of WolfDawn-compared control codes in *text*."""
|
||||
if not isinstance(text, str) or not text:
|
||||
return []
|
||||
return [m.group(0) for m in WOLF_INLINE_CODE_RE.finditer(text)]
|
||||
|
||||
|
||||
def _normalize_font_size_codes(text: str) -> str:
|
||||
"""Replace every ``\\f[N]`` with a placeholder so size values are ignored."""
|
||||
if not isinstance(text, str) or not text:
|
||||
return text if isinstance(text, str) else ""
|
||||
return WOLF_FONT_SIZE_RE.sub(r"\\f[_]", text)
|
||||
|
||||
|
||||
def font_size_codes_differ(source: str, text: str) -> bool:
|
||||
"""True when *source* and *text* differ only in ``\\f[N]`` size values / count.
|
||||
|
||||
Used after ``wolf names-wrap`` shrinks overflow with a leading ``\\f[N]`` and
|
||||
scales inline font codes. Other control-code drift (missing ``\\c``, etc.)
|
||||
returns False so inject still requires an explicit allow-code-drift.
|
||||
"""
|
||||
if not isinstance(source, str) or not isinstance(text, str):
|
||||
return False
|
||||
if source == text:
|
||||
return False
|
||||
src_codes = _control_code_tokens(source)
|
||||
txt_codes = _control_code_tokens(text)
|
||||
if src_codes == txt_codes:
|
||||
return False
|
||||
# Same non-font codes, but font sizes / leading shrink differ.
|
||||
src_norm = _normalize_font_size_codes(source)
|
||||
txt_norm = _normalize_font_size_codes(text)
|
||||
if _control_code_tokens(src_norm) == _control_code_tokens(txt_norm):
|
||||
return True
|
||||
# Leading shrink adds an extra \\f that source never had.
|
||||
src_f = WOLF_FONT_SIZE_RE.findall(source)
|
||||
txt_f = WOLF_FONT_SIZE_RE.findall(text)
|
||||
if not txt_f:
|
||||
return False
|
||||
src_non_f = [c for c in src_codes if not WOLF_FONT_SIZE_RE.fullmatch(c)]
|
||||
txt_non_f = [c for c in txt_codes if not WOLF_FONT_SIZE_RE.fullmatch(c)]
|
||||
return src_non_f == txt_non_f and (src_f != txt_f or len(txt_f) > len(src_f))
|
||||
|
||||
|
||||
def names_doc_has_font_size_drift(doc: dict[str, Any]) -> bool:
|
||||
"""True when any names.json entry has font-size-only control-code drift."""
|
||||
if not isinstance(doc, dict) or doc.get("kind") != "names":
|
||||
return False
|
||||
for entry in doc.get("names") or []:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
src, txt = entry.get("source"), entry.get("text")
|
||||
if isinstance(src, str) and isinstance(txt, str) and font_size_codes_differ(src, txt):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _tokenize(rest: str) -> list[tuple[str, str]]:
|
||||
"""Split *rest* into ``('lit', ...)`` and ``('code', ...)`` tokens."""
|
||||
|
|
|
|||
489
util/wolfdawn/db_classify.py
Normal file
489
util/wolfdawn/db_classify.py
Normal file
|
|
@ -0,0 +1,489 @@
|
|||
"""WolfDawn database sheet classification for the WOLF translation workflow.
|
||||
|
||||
Database JSON (``kind: "db"``) varies widely across games: standard RPG sheets
|
||||
(items, skills, states) vs custom narrative sheets (event dialogue, profiles).
|
||||
This module classifies each ``groups[]`` sheet into tiers so translators can
|
||||
translate foundational content before narrative-heavy custom tables.
|
||||
|
||||
Tiers:
|
||||
foundation - standard RPG sheets (items, skills, weapons, enemies, etc.)
|
||||
system - UI / system messages in database fields
|
||||
narrative - custom dialogue / profile sheets (defer until foundation is done)
|
||||
unknown - unclassified sheets (included in foundation runs by default)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from util.wolfdawn.names import _LABEL_SEP
|
||||
|
||||
TIER_FOUNDATION = "foundation"
|
||||
TIER_SYSTEM = "system"
|
||||
TIER_NARRATIVE = "narrative"
|
||||
TIER_UNKNOWN = "unknown"
|
||||
|
||||
FOUNDATION_TIERS = frozenset({TIER_FOUNDATION, TIER_SYSTEM, TIER_UNKNOWN})
|
||||
NARRATIVE_TIERS = frozenset({TIER_NARRATIVE})
|
||||
|
||||
DB_PROFILE_NAME = "db_profile.json"
|
||||
|
||||
# Japanese halves of standard WolfDawn bilingual typeName labels.
|
||||
STANDARD_SHEETS_JP = frozenset({
|
||||
"武器",
|
||||
"防具",
|
||||
"技能",
|
||||
"アイテム",
|
||||
"状態設定",
|
||||
"用語設定",
|
||||
"戦闘コマンド",
|
||||
"システム設定",
|
||||
"属性名の設定",
|
||||
"主人公ステータス",
|
||||
"敵グループ",
|
||||
"敵キャラ個体データ",
|
||||
})
|
||||
|
||||
STANDARD_FIELDS_RE = re.compile(
|
||||
r"説明|Description|商品説明|セーブ|ロード|使用時文章|発生時の文章|回復時の文章",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
SYSTEM_FIELDS_RE = re.compile(
|
||||
r"メッセージ|Message|タイトル|用語|Terms?",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
CUSTOM_SHEET_RE = re.compile(
|
||||
r"^[■├■]|イベント|セリフ|プロフィール|MOB|感想|所持アイテム|CG",
|
||||
)
|
||||
DIALOGUE_FIELDS_RE = re.compile(
|
||||
r"セリフ|コメント|相手|行為|台詞|会話|メッセージ_",
|
||||
)
|
||||
|
||||
ARCHETYPE_CLASSIC = "classic_rpg"
|
||||
ARCHETYPE_DB_HEAVY = "simulation_db_heavy"
|
||||
ARCHETYPE_HYBRID = "hybrid"
|
||||
ARCHETYPE_UNKNOWN = "unknown"
|
||||
|
||||
|
||||
@dataclass
|
||||
class DbGroupInfo:
|
||||
"""One classified database sheet (a ``groups[]`` entry)."""
|
||||
|
||||
json_file: str
|
||||
type_name: str
|
||||
tier: str
|
||||
line_count: int
|
||||
sample_fields: list[str] = field(default_factory=list)
|
||||
default_checked: bool = True
|
||||
|
||||
@property
|
||||
def key(self) -> str:
|
||||
return group_key(self.json_file, self.type_name)
|
||||
|
||||
|
||||
def group_key(json_file: str, type_name: str) -> str:
|
||||
"""Stable id for a sheet: ``DataBase.project.json|Skill · 技能``."""
|
||||
return f"{json_file}|{type_name}"
|
||||
|
||||
|
||||
def _japanese_sheet_name(type_name: str) -> str:
|
||||
if _LABEL_SEP in type_name:
|
||||
_, _, jp = type_name.partition(_LABEL_SEP)
|
||||
return jp.strip()
|
||||
return type_name.strip()
|
||||
|
||||
|
||||
def json_file_from_doc(doc: dict[str, Any], path: Path | None = None) -> str:
|
||||
if path is not None:
|
||||
return path.name
|
||||
file_field = str(doc.get("file") or "")
|
||||
if file_field:
|
||||
return f"{file_field}.json" if not file_field.endswith(".json") else file_field
|
||||
return "unknown.project.json"
|
||||
|
||||
|
||||
def classify_group_tier(type_name: str, lines: list[dict[str, Any]]) -> str:
|
||||
"""Return the tier for one database sheet."""
|
||||
jp = _japanese_sheet_name(type_name)
|
||||
if CUSTOM_SHEET_RE.search(type_name) or CUSTOM_SHEET_RE.search(jp):
|
||||
return TIER_NARRATIVE
|
||||
if jp in STANDARD_SHEETS_JP:
|
||||
return TIER_FOUNDATION
|
||||
|
||||
field_names = [str(line.get("fieldName") or "") for line in lines]
|
||||
dialogue_hits = sum(1 for fn in field_names if DIALOGUE_FIELDS_RE.search(fn))
|
||||
standard_hits = sum(1 for fn in field_names if STANDARD_FIELDS_RE.search(fn))
|
||||
system_hits = sum(1 for fn in field_names if SYSTEM_FIELDS_RE.search(fn))
|
||||
|
||||
if lines and dialogue_hits > max(standard_hits, system_hits):
|
||||
return TIER_NARRATIVE
|
||||
if standard_hits > 0:
|
||||
return TIER_FOUNDATION
|
||||
if system_hits > 0:
|
||||
return TIER_SYSTEM
|
||||
if not lines:
|
||||
return TIER_UNKNOWN
|
||||
return TIER_UNKNOWN
|
||||
|
||||
|
||||
def classify_db_document(
|
||||
doc: dict[str, Any],
|
||||
*,
|
||||
json_file: str | None = None,
|
||||
path: Path | None = None,
|
||||
) -> list[DbGroupInfo]:
|
||||
"""Classify every group in one ``kind: db`` document."""
|
||||
if doc.get("kind") != "db":
|
||||
return []
|
||||
jf = json_file or json_file_from_doc(doc, path)
|
||||
groups: list[DbGroupInfo] = []
|
||||
for group in doc.get("groups") or []:
|
||||
type_name = str(group.get("typeName") or "")
|
||||
lines = group.get("lines") or []
|
||||
tier = classify_group_tier(type_name, lines)
|
||||
fields: Counter[str] = Counter()
|
||||
for line in lines:
|
||||
fn = str(line.get("fieldName") or "").strip()
|
||||
if fn:
|
||||
fields[fn] += 1
|
||||
sample = [fn for fn, _ in fields.most_common(3)]
|
||||
default_checked = tier in FOUNDATION_TIERS
|
||||
groups.append(
|
||||
DbGroupInfo(
|
||||
json_file=jf,
|
||||
type_name=type_name,
|
||||
tier=tier,
|
||||
line_count=len(lines),
|
||||
sample_fields=sample,
|
||||
default_checked=default_checked,
|
||||
)
|
||||
)
|
||||
return groups
|
||||
|
||||
|
||||
def _count_json_lines(doc: dict[str, Any]) -> int:
|
||||
kind = doc.get("kind")
|
||||
if kind in ("map", "common"):
|
||||
return sum(len(scene.get("lines") or []) for scene in doc.get("scenes") or [])
|
||||
if kind == "gamedat":
|
||||
return len(doc.get("lines") or [])
|
||||
if kind == "txt-dir":
|
||||
return sum(
|
||||
len(f.get("lines") or [])
|
||||
for f in doc.get("files") or []
|
||||
)
|
||||
if kind == "txt":
|
||||
return len(doc.get("lines") or [])
|
||||
return 0
|
||||
|
||||
|
||||
def _load_json(path: Path) -> dict[str, Any] | None:
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8-sig"))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ContentDistribution:
|
||||
"""Text distribution across WolfDawn extraction kinds."""
|
||||
|
||||
db_lines: int = 0
|
||||
db_foundation_lines: int = 0
|
||||
db_narrative_lines: int = 0
|
||||
db_system_lines: int = 0
|
||||
db_unknown_lines: int = 0
|
||||
map_lines: int = 0
|
||||
map_files: int = 0
|
||||
common_lines: int = 0
|
||||
gamedat_lines: int = 0
|
||||
evtext_lines: int = 0
|
||||
groups: list[DbGroupInfo] = field(default_factory=list)
|
||||
archetype: str = ARCHETYPE_UNKNOWN
|
||||
|
||||
@property
|
||||
def total_event_lines(self) -> int:
|
||||
return self.map_lines + self.common_lines + self.gamedat_lines + self.evtext_lines
|
||||
|
||||
@property
|
||||
def db_narrative_pct(self) -> float:
|
||||
if self.db_lines <= 0:
|
||||
return 0.0
|
||||
return 100.0 * self.db_narrative_lines / self.db_lines
|
||||
|
||||
|
||||
def analyze_content_distribution(files_dir: str | Path) -> ContentDistribution:
|
||||
"""Scan staged ``files/`` JSON and classify database sheets."""
|
||||
base = Path(files_dir)
|
||||
dist = ContentDistribution()
|
||||
if not base.is_dir():
|
||||
return dist
|
||||
|
||||
for path in sorted(base.glob("*.json")):
|
||||
doc = _load_json(path)
|
||||
if not doc:
|
||||
continue
|
||||
kind = doc.get("kind")
|
||||
if kind == "db":
|
||||
groups = classify_db_document(doc, path=path)
|
||||
dist.groups.extend(groups)
|
||||
for g in groups:
|
||||
dist.db_lines += g.line_count
|
||||
if g.tier == TIER_FOUNDATION:
|
||||
dist.db_foundation_lines += g.line_count
|
||||
elif g.tier == TIER_NARRATIVE:
|
||||
dist.db_narrative_lines += g.line_count
|
||||
elif g.tier == TIER_SYSTEM:
|
||||
dist.db_system_lines += g.line_count
|
||||
else:
|
||||
dist.db_unknown_lines += g.line_count
|
||||
elif kind == "map":
|
||||
dist.map_files += 1
|
||||
dist.map_lines += _count_json_lines(doc)
|
||||
elif kind == "common":
|
||||
dist.common_lines += _count_json_lines(doc)
|
||||
elif kind == "gamedat":
|
||||
dist.gamedat_lines += _count_json_lines(doc)
|
||||
elif kind == "txt-dir":
|
||||
dist.evtext_lines += _count_json_lines(doc)
|
||||
|
||||
dist.archetype = _infer_archetype(dist)
|
||||
return dist
|
||||
|
||||
|
||||
def _infer_archetype(dist: ContentDistribution) -> str:
|
||||
total = dist.db_lines + dist.total_event_lines
|
||||
if total <= 0:
|
||||
return ARCHETYPE_UNKNOWN
|
||||
db_share = dist.db_lines / total
|
||||
narrative_share = dist.db_narrative_lines / max(dist.db_lines, 1)
|
||||
if db_share > 0.5 and narrative_share > 0.4:
|
||||
return ARCHETYPE_DB_HEAVY
|
||||
if dist.total_event_lines > dist.db_lines * 2 and dist.db_narrative_lines < dist.db_lines * 0.2:
|
||||
return ARCHETYPE_CLASSIC
|
||||
return ARCHETYPE_HYBRID
|
||||
|
||||
|
||||
def format_discovery_summary(dist: ContentDistribution) -> str:
|
||||
"""Multi-line discovery report for the Database workflow step."""
|
||||
lines = ["This game's text distribution:"]
|
||||
if dist.db_lines:
|
||||
pct_narr = f"{dist.db_narrative_pct:.0f}% narrative sheets"
|
||||
lines.append(
|
||||
f" Database: {dist.db_lines:,} lines "
|
||||
f"({dist.db_foundation_lines + dist.db_system_lines:,} foundation/system, "
|
||||
f"{dist.db_narrative_lines:,} narrative, {pct_narr})"
|
||||
)
|
||||
else:
|
||||
lines.append(" Database: (no DB JSON in files/)")
|
||||
lines.append(f" Maps: {dist.map_files} file(s), {dist.map_lines:,} dialogue lines")
|
||||
lines.append(f" CommonEvent: {dist.common_lines:,} lines")
|
||||
if dist.gamedat_lines:
|
||||
lines.append(f" Game.dat: {dist.gamedat_lines:,} lines")
|
||||
if dist.evtext_lines:
|
||||
lines.append(f" Evtext: {dist.evtext_lines:,} lines")
|
||||
|
||||
lines.append("")
|
||||
lines.append(archetype_guidance(dist))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def archetype_guidance(dist: ContentDistribution) -> str:
|
||||
foundation = dist.db_foundation_lines + dist.db_system_lines + dist.db_unknown_lines
|
||||
if dist.archetype == ARCHETYPE_DB_HEAVY:
|
||||
return (
|
||||
"Recommended order: Names → DB foundation "
|
||||
f"({foundation:,} lines) → DB narrative "
|
||||
f"({dist.db_narrative_lines:,} lines) → Maps/events.\n"
|
||||
"This game stores most story dialogue in custom database sheets, not maps."
|
||||
)
|
||||
if dist.archetype == ARCHETYPE_CLASSIC:
|
||||
return (
|
||||
"Recommended order: Names → DB foundation "
|
||||
f"({foundation:,} lines) → Maps/events "
|
||||
f"({dist.total_event_lines:,} lines).\n"
|
||||
"Classic layout: most story dialogue is in maps and common events. "
|
||||
"You can skip DB narrative if there are no custom sheets."
|
||||
)
|
||||
if dist.db_narrative_lines == 0:
|
||||
return (
|
||||
"Recommended order: Names → DB foundation "
|
||||
f"({foundation:,} lines) → Maps/events.\n"
|
||||
"No custom narrative DB sheets detected — skip narrative DB translation."
|
||||
)
|
||||
return (
|
||||
"Recommended order: Names → DB foundation "
|
||||
f"({foundation:,} lines) → DB narrative "
|
||||
f"({dist.db_narrative_lines:,} lines) → Maps/events.\n"
|
||||
"Hybrid layout: significant text in both database sheets and maps."
|
||||
)
|
||||
|
||||
|
||||
def build_ai_audit_prompt(dist: ContentDistribution) -> str:
|
||||
"""Compact prompt for AI-assisted database structure analysis."""
|
||||
group_lines = []
|
||||
for g in sorted(dist.groups, key=lambda x: -x.line_count):
|
||||
fields = ", ".join(g.sample_fields[:2]) if g.sample_fields else "(none)"
|
||||
group_lines.append(
|
||||
f" - {g.json_file} | {g.type_name}: {g.line_count} lines, "
|
||||
f"tier={g.tier}, fields: {fields}"
|
||||
)
|
||||
groups_text = "\n".join(group_lines) if group_lines else " (no database groups found)"
|
||||
|
||||
return (
|
||||
"You are an expert Japanese WOLF RPG Editor game analyst.\n"
|
||||
"\n"
|
||||
"<task>\n"
|
||||
"Classify this game's database sheets for translation workflow. "
|
||||
"Return ONLY valid JSON (no markdown fences) matching this schema:\n"
|
||||
"{\n"
|
||||
' "archetype": "classic_rpg" | "simulation_db_heavy" | "hybrid",\n'
|
||||
' "foundation_groups": ["DataBase.project.json|Skill · 技能", ...],\n'
|
||||
' "narrative_groups": ["DataBase.project.json|■イベント(名前)", ...],\n'
|
||||
' "defer_groups": [],\n'
|
||||
' "notes": "one sentence about where this game stores dialogue"\n'
|
||||
"}\n"
|
||||
"\n"
|
||||
"foundation_groups = items, skills, descriptions, system UI (translate first).\n"
|
||||
"narrative_groups = custom dialogue/profile sheets (translate after foundation).\n"
|
||||
"defer_groups = sheets to skip entirely (rare).\n"
|
||||
"Use the exact group keys shown below (json_file|typeName).\n"
|
||||
"</task>\n"
|
||||
"\n"
|
||||
"<auto_summary>\n"
|
||||
f"{format_discovery_summary(dist)}\n"
|
||||
"</auto_summary>\n"
|
||||
"\n"
|
||||
"<database_groups>\n"
|
||||
f"{groups_text}\n"
|
||||
"</database_groups>\n"
|
||||
)
|
||||
|
||||
|
||||
def db_profile_path(work_dir: str | Path) -> Path:
|
||||
return Path(work_dir) / DB_PROFILE_NAME
|
||||
|
||||
|
||||
def load_db_profile(work_dir: str | Path) -> dict[str, Any]:
|
||||
path = db_profile_path(work_dir)
|
||||
if not path.is_file():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def save_db_profile(work_dir: str | Path, profile: dict[str, Any]) -> None:
|
||||
path = db_profile_path(work_dir)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(profile, ensure_ascii=False, indent=4) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def merge_profile_with_groups(
|
||||
profile: dict[str, Any],
|
||||
groups: list[DbGroupInfo],
|
||||
) -> dict[str, bool]:
|
||||
"""Return ``{group_key: checked}`` merging AI profile with heuristic defaults."""
|
||||
selected: set[str] = set()
|
||||
|
||||
for key in ("foundation_groups", "narrative_groups"):
|
||||
for item in profile.get(key) or []:
|
||||
if isinstance(item, str) and item.strip():
|
||||
selected.add(item.strip())
|
||||
defer = {str(x) for x in (profile.get("defer_groups") or []) if str(x).strip()}
|
||||
|
||||
result: dict[str, bool] = {}
|
||||
for g in groups:
|
||||
if g.key in defer:
|
||||
result[g.key] = False
|
||||
elif g.key in selected:
|
||||
result[g.key] = True
|
||||
elif profile.get("foundation_groups") or profile.get("narrative_groups"):
|
||||
# Profile exists but group not listed: use tier default.
|
||||
result[g.key] = g.default_checked
|
||||
else:
|
||||
result[g.key] = g.default_checked
|
||||
return result
|
||||
|
||||
|
||||
def selected_groups_for_tiers(
|
||||
groups: list[DbGroupInfo],
|
||||
tiers: frozenset[str],
|
||||
) -> list[str]:
|
||||
return [g.key for g in groups if g.tier in tiers]
|
||||
|
||||
|
||||
def parse_db_filter_tiers(raw: str) -> frozenset[str]:
|
||||
text = (raw or "").strip()
|
||||
if not text:
|
||||
return frozenset()
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
if isinstance(parsed, list):
|
||||
return frozenset(str(x).strip() for x in parsed if str(x).strip())
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return frozenset(part.strip() for part in text.split(",") if part.strip())
|
||||
|
||||
|
||||
def parse_db_filter_groups(raw: str) -> frozenset[str]:
|
||||
text = (raw or "").strip()
|
||||
if not text:
|
||||
return frozenset()
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
if isinstance(parsed, list):
|
||||
return frozenset(str(x).strip() for x in parsed if str(x).strip())
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return frozenset(part.strip() for part in text.split(",") if part.strip())
|
||||
|
||||
|
||||
def load_db_filter_config() -> tuple[frozenset[str], frozenset[str]]:
|
||||
"""Read ``wolfDbIncludeTiers`` / ``wolfDbIncludeGroups`` from ``.env``."""
|
||||
import os
|
||||
|
||||
return (
|
||||
parse_db_filter_tiers(os.getenv("wolfDbIncludeTiers", "")),
|
||||
parse_db_filter_groups(os.getenv("wolfDbIncludeGroups", "")),
|
||||
)
|
||||
|
||||
|
||||
def group_matches_filter(
|
||||
json_file: str,
|
||||
type_name: str,
|
||||
include_tiers: frozenset[str],
|
||||
include_groups: frozenset[str],
|
||||
) -> bool:
|
||||
"""True when a DB sheet should be translated under the active filter."""
|
||||
if not include_tiers and not include_groups:
|
||||
return True
|
||||
key = group_key(json_file, type_name)
|
||||
if include_groups:
|
||||
return key in include_groups
|
||||
# Tier-only filter: need to classify on the fly without line data.
|
||||
tier = classify_group_tier(type_name, [])
|
||||
return tier in include_tiers
|
||||
|
||||
|
||||
def import_ai_profile(raw: str) -> dict[str, Any]:
|
||||
"""Parse AI-returned JSON profile text (strips markdown fences if present)."""
|
||||
text = raw.strip()
|
||||
if text.startswith("```"):
|
||||
lines = text.splitlines()
|
||||
if lines and lines[0].startswith("```"):
|
||||
lines = lines[1:]
|
||||
if lines and lines[-1].strip() == "```":
|
||||
lines = lines[:-1]
|
||||
text = "\n".join(lines)
|
||||
data = json.loads(text)
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("Profile must be a JSON object")
|
||||
return data
|
||||
496
util/wolfdawn/inject.py
Normal file
496
util/wolfdawn/inject.py
Normal file
|
|
@ -0,0 +1,496 @@
|
|||
"""WolfDawn inject orchestration for translated JSON files.
|
||||
|
||||
One list of files in ``translated/``, user picks some or all, each file gets a
|
||||
clear pass/fail result. ``names.json`` uses ``names-inject`` (must run before
|
||||
per-file ``strings-inject`` when both are selected).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from util.wolfdawn import codes as wolf_codes
|
||||
from util.wolfdawn import db_dat_sibling
|
||||
from util.wolfdawn import originals as wolf_originals
|
||||
from util import wolfdawn
|
||||
|
||||
NAMES_JSON = "names.json"
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileInjectResult:
|
||||
json_name: str
|
||||
success: bool
|
||||
summary: str
|
||||
applied: int | None = None
|
||||
detail: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class InjectReport:
|
||||
files: list[FileInjectResult] = field(default_factory=list)
|
||||
sync_failures: list[tuple[str, str]] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return all(r.success for r in self.files) and not self.sync_failures
|
||||
|
||||
@property
|
||||
def succeeded(self) -> list[FileInjectResult]:
|
||||
return [r for r in self.files if r.success]
|
||||
|
||||
@property
|
||||
def failed(self) -> list[FileInjectResult]:
|
||||
return [r for r in self.files if not r.success]
|
||||
|
||||
|
||||
def list_injectable(translated_dir: Path, manifest_entries: list[dict]) -> list[str]:
|
||||
"""Return sorted JSON filenames in *translated_dir* that the manifest can inject."""
|
||||
manifest_json = {e["json"] for e in manifest_entries if e.get("json")}
|
||||
if not translated_dir.is_dir():
|
||||
return []
|
||||
return sorted(
|
||||
p.name
|
||||
for p in translated_dir.glob("*.json")
|
||||
if p.is_file() and p.name in manifest_json
|
||||
)
|
||||
|
||||
|
||||
def orig_base_for(entry: dict, data_dir: Path, originals_dir: Path) -> Path:
|
||||
"""Pristine snapshot path for one manifest entry."""
|
||||
base = Path(entry["base"])
|
||||
try:
|
||||
rel = base.relative_to(data_dir)
|
||||
except ValueError:
|
||||
rel = Path(base.name)
|
||||
return originals_dir / rel
|
||||
|
||||
|
||||
def restore_live_from_originals(
|
||||
entries: list[dict],
|
||||
data_dir: Path,
|
||||
originals_dir: Path,
|
||||
) -> list[str]:
|
||||
"""Copy pristine originals onto live Data/. Return warning messages."""
|
||||
warnings: list[str] = []
|
||||
for entry in entries:
|
||||
if entry.get("kind") == "names":
|
||||
continue
|
||||
orig = orig_base_for(entry, data_dir, originals_dir)
|
||||
live = Path(entry["base"])
|
||||
if not orig.exists():
|
||||
warnings.append(f"{entry['json']}: no pristine original at {orig.name}")
|
||||
continue
|
||||
try:
|
||||
if orig.is_dir():
|
||||
if live.exists():
|
||||
shutil.rmtree(live)
|
||||
shutil.copytree(orig, live)
|
||||
else:
|
||||
live.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(orig, live)
|
||||
if entry.get("kind") == "db":
|
||||
dat_orig = db_dat_sibling(orig)
|
||||
dat_live = db_dat_sibling(live)
|
||||
if dat_orig.is_file():
|
||||
shutil.copy2(dat_orig, dat_live)
|
||||
except Exception as exc:
|
||||
warnings.append(f"{entry['json']}: could not restore live binary ({exc})")
|
||||
return warnings
|
||||
|
||||
|
||||
def repair_inject_json(src: Path) -> Path:
|
||||
"""Auto-repair WOLF inline codes in a translated JSON before inject."""
|
||||
data = json.loads(src.read_text(encoding="utf-8-sig"))
|
||||
data, repairs = wolf_codes.repair_document(data)
|
||||
if repairs:
|
||||
src.write_text(
|
||||
json.dumps(data, ensure_ascii=False, indent=4) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return src
|
||||
|
||||
|
||||
def _wolf_output_snippet(stdout: str, stderr: str, *, limit: int = 400) -> str:
|
||||
text = (stdout or "").strip()
|
||||
if stderr and stderr.strip():
|
||||
text = f"{text}\n{stderr.strip()}".strip() if text else stderr.strip()
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return text[: limit - 3] + "..."
|
||||
|
||||
|
||||
def _interpret_strings_result(json_name: str, res: wolfdawn.WolfResult) -> FileInjectResult:
|
||||
cli_err = wolfdawn.parse_inject_cli_error(res.stdout, res.stderr)
|
||||
applied, drifted = wolfdawn.parse_strings_inject_counts(res.stdout, res.stderr)
|
||||
untranslated = wolfdawn.parse_strings_inject_untranslated(res.stdout, res.stderr)
|
||||
mismatches = wolfdawn.parse_strings_inject_mismatches(res.stdout, res.stderr)
|
||||
detail = _wolf_output_snippet(res.stdout, res.stderr)
|
||||
|
||||
if not res.ok:
|
||||
reason = cli_err or f"wolf exited {res.returncode}"
|
||||
return FileInjectResult(json_name, False, reason, applied=applied, detail=detail)
|
||||
|
||||
if wolfdawn.inject_had_applied(applied):
|
||||
msg = f"applied {applied} line(s)"
|
||||
if drifted:
|
||||
msg += f" ({drifted} drifted)"
|
||||
if untranslated:
|
||||
msg += f" ({untranslated} skipped by safety guard)"
|
||||
return FileInjectResult(json_name, True, msg, applied=applied, detail=detail)
|
||||
|
||||
if (drifted or 0) > 0:
|
||||
return FileInjectResult(
|
||||
json_name,
|
||||
False,
|
||||
f"0 applied, {drifted} drifted (live Data/ no longer matches Japanese sources)",
|
||||
applied=0,
|
||||
detail=detail,
|
||||
)
|
||||
|
||||
if untranslated or mismatches:
|
||||
parts = []
|
||||
if untranslated:
|
||||
parts.append(f"{untranslated} line(s) skipped by safety guard")
|
||||
if mismatches:
|
||||
parts.append(f"{len(mismatches)} control-code mismatch(es)")
|
||||
return FileInjectResult(
|
||||
json_name, False, "; ".join(parts), applied=0, detail=detail
|
||||
)
|
||||
|
||||
return FileInjectResult(json_name, True, "no changes needed", applied=0, detail=detail)
|
||||
|
||||
|
||||
def _interpret_names_result(json_name: str, res: wolfdawn.WolfResult) -> FileInjectResult:
|
||||
cli_err = wolfdawn.parse_inject_cli_error(res.stdout, res.stderr)
|
||||
applied, drifted = wolfdawn.parse_names_inject_counts(res.stdout, res.stderr)
|
||||
detail = _wolf_output_snippet(res.stdout, res.stderr)
|
||||
|
||||
if not res.ok:
|
||||
reason = cli_err or f"wolf exited {res.returncode}"
|
||||
return FileInjectResult(json_name, False, reason, applied=applied, detail=detail)
|
||||
|
||||
if wolfdawn.inject_had_applied(applied):
|
||||
msg = f"applied {applied} name change(s)"
|
||||
if drifted:
|
||||
msg += f" ({drifted} unmatched)"
|
||||
return FileInjectResult(json_name, True, msg, applied=applied, detail=detail)
|
||||
|
||||
if res.ok and (drifted or 0) > 0:
|
||||
return FileInjectResult(
|
||||
json_name,
|
||||
False,
|
||||
(
|
||||
f"0 applied, {drifted} unmatched "
|
||||
"(re-run Step 0 extract on the untranslated game)"
|
||||
),
|
||||
applied=0,
|
||||
detail=detail,
|
||||
)
|
||||
|
||||
return FileInjectResult(
|
||||
json_name,
|
||||
False,
|
||||
_STALE_NAMES_MSG,
|
||||
applied=0,
|
||||
detail=detail,
|
||||
)
|
||||
|
||||
|
||||
_STALE_NAMES_MSG = (
|
||||
"0 name changes would apply. wolf_json/originals/ still holds old English "
|
||||
"instead of Japanese sources (usually after a previous inject). Rebuilt from "
|
||||
".wolf archives automatically when possible; if this persists, re-run Step 0 "
|
||||
"on the untranslated game."
|
||||
)
|
||||
|
||||
|
||||
def _prepare_for_names_inject(
|
||||
names_src: Path,
|
||||
manifest_entries: list[dict],
|
||||
data_dir: Path,
|
||||
originals_dir: Path,
|
||||
game_root: Path,
|
||||
log_fn: Callable[[str], None] | None = None,
|
||||
*,
|
||||
allow_code_drift: bool = False,
|
||||
) -> str | None:
|
||||
"""Restore live Data/ and ensure names-inject can match Japanese sources."""
|
||||
emit = log_fn or (lambda _msg: None)
|
||||
|
||||
def _restore() -> None:
|
||||
for warning in restore_live_from_originals(
|
||||
manifest_entries, data_dir, originals_dir
|
||||
):
|
||||
emit(f" ⚠ {warning}")
|
||||
|
||||
_restore()
|
||||
would_apply = wolf_originals.names_inject_would_apply(
|
||||
names_src, data_dir, allow_code_drift=allow_code_drift
|
||||
)
|
||||
if wolfdawn.inject_had_applied(would_apply):
|
||||
return None
|
||||
|
||||
emit(" ⚠ names.json would apply 0 changes — rebuilding pristine originals…")
|
||||
if wolf_originals.rebuild_originals_from_archives(
|
||||
game_root, originals_dir, force=True, log_fn=log_fn
|
||||
):
|
||||
_restore()
|
||||
would_apply = wolf_originals.names_inject_would_apply(
|
||||
names_src, data_dir, allow_code_drift=allow_code_drift
|
||||
)
|
||||
if wolfdawn.inject_had_applied(would_apply):
|
||||
emit(f" ✓ ready — dry run would apply {would_apply} name change(s)")
|
||||
return None
|
||||
|
||||
return _STALE_NAMES_MSG
|
||||
|
||||
|
||||
def _inject_names(
|
||||
names_src: Path,
|
||||
data_dir: Path,
|
||||
*,
|
||||
allow_code_drift: bool,
|
||||
en_punct: bool,
|
||||
log_fn: Callable[[str], None] | None = None,
|
||||
) -> FileInjectResult:
|
||||
emit = log_fn or (lambda _msg: None)
|
||||
would_apply = wolf_originals.names_inject_would_apply(
|
||||
names_src, data_dir, allow_code_drift=allow_code_drift
|
||||
)
|
||||
if not wolfdawn.inject_had_applied(would_apply):
|
||||
return FileInjectResult(
|
||||
NAMES_JSON,
|
||||
False,
|
||||
(
|
||||
"inject refused — dry run would apply 0 name changes "
|
||||
"(live Data/ still lacks Japanese sources; rebuild originals first)"
|
||||
),
|
||||
)
|
||||
|
||||
emit(f" dry run: {would_apply} name change(s) pending")
|
||||
res = wolfdawn.names_inject(
|
||||
str(names_src),
|
||||
str(data_dir),
|
||||
allow_code_drift=allow_code_drift,
|
||||
en_punct=en_punct,
|
||||
log_fn=None,
|
||||
)
|
||||
result = _interpret_names_result(NAMES_JSON, res)
|
||||
if not result.success:
|
||||
return result
|
||||
|
||||
remaining = wolf_originals.names_inject_would_apply(
|
||||
names_src, data_dir, allow_code_drift=allow_code_drift
|
||||
)
|
||||
if wolfdawn.inject_had_applied(remaining):
|
||||
result = FileInjectResult(
|
||||
NAMES_JSON,
|
||||
False,
|
||||
(
|
||||
f"only partial apply — {result.summary}, but dry run still shows "
|
||||
f"{remaining} pending (restart Game.exe and report this)"
|
||||
),
|
||||
applied=result.applied,
|
||||
detail=result.detail,
|
||||
)
|
||||
elif result.success and wolfdawn.inject_had_applied(result.applied):
|
||||
result = FileInjectResult(
|
||||
NAMES_JSON,
|
||||
True,
|
||||
result.summary + " — restart Game.exe to see changes",
|
||||
applied=result.applied,
|
||||
detail=result.detail,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _inject_strings(
|
||||
json_name: str,
|
||||
entry: dict,
|
||||
inject_src: Path,
|
||||
data_dir: Path,
|
||||
originals_dir: Path,
|
||||
*,
|
||||
allow_code_drift: bool,
|
||||
en_punct: bool,
|
||||
) -> FileInjectResult:
|
||||
orig = orig_base_for(entry, data_dir, originals_dir)
|
||||
out = entry["base"]
|
||||
if not orig.exists():
|
||||
return FileInjectResult(
|
||||
json_name,
|
||||
False,
|
||||
f"no pristine original at {orig} (re-run Step 0 extract)",
|
||||
)
|
||||
|
||||
if entry.get("kind") == "db" and not db_dat_sibling(orig).is_file():
|
||||
return FileInjectResult(
|
||||
json_name,
|
||||
False,
|
||||
"missing database .dat pair in originals (re-run Step 0 extract)",
|
||||
)
|
||||
|
||||
res = wolfdawn.strings_inject(
|
||||
str(inject_src),
|
||||
str(orig),
|
||||
out,
|
||||
allow_code_drift=allow_code_drift,
|
||||
en_punct=en_punct,
|
||||
log_fn=None,
|
||||
)
|
||||
return _interpret_strings_result(json_name, res)
|
||||
|
||||
|
||||
def ensure_db_dat_snapshots(
|
||||
entries: list[dict],
|
||||
data_dir: Path,
|
||||
originals_dir: Path,
|
||||
) -> None:
|
||||
"""Backfill missing database ``.dat`` files in *originals_dir* from live Data/."""
|
||||
for entry in entries:
|
||||
if entry.get("kind") != "db":
|
||||
continue
|
||||
proj_orig = orig_base_for(entry, data_dir, originals_dir)
|
||||
dat_orig = db_dat_sibling(proj_orig)
|
||||
if dat_orig.exists():
|
||||
continue
|
||||
dat_live = db_dat_sibling(Path(entry["base"]))
|
||||
if not dat_live.is_file():
|
||||
continue
|
||||
dat_orig.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(dat_live, dat_orig)
|
||||
|
||||
|
||||
def inject_selected(
|
||||
selected: list[str],
|
||||
*,
|
||||
manifest_entries: list[dict],
|
||||
data_dir: Path,
|
||||
originals_dir: Path,
|
||||
translated_dir: Path,
|
||||
game_root: Path,
|
||||
allow_code_drift: bool = False,
|
||||
en_punct: bool = False,
|
||||
log_fn: Callable[[str], None] | None = None,
|
||||
) -> InjectReport:
|
||||
"""Inject *selected* translated JSON files. Returns a per-file report."""
|
||||
emit = log_fn or (lambda _msg: None)
|
||||
report = InjectReport()
|
||||
if not selected:
|
||||
return report
|
||||
|
||||
by_json = {e["json"]: e for e in manifest_entries if e.get("json")}
|
||||
ordered = sorted(set(selected), key=lambda n: (n != NAMES_JSON, n.lower()))
|
||||
|
||||
missing_manifest = [n for n in ordered if n not in by_json]
|
||||
missing_translated = [
|
||||
n for n in ordered if not (translated_dir / n).is_file()
|
||||
]
|
||||
for name in missing_manifest:
|
||||
report.files.append(
|
||||
FileInjectResult(name, False, "not listed in manifest (re-run Step 0 extract)")
|
||||
)
|
||||
for name in missing_translated:
|
||||
if name in by_json:
|
||||
report.files.append(
|
||||
FileInjectResult(name, False, f"not found in {translated_dir.name}/")
|
||||
)
|
||||
|
||||
todo = [
|
||||
n for n in ordered
|
||||
if n in by_json and (translated_dir / n).is_file()
|
||||
]
|
||||
if not todo:
|
||||
return report
|
||||
|
||||
if NAMES_JSON in todo:
|
||||
emit("Preparing live Data/ for names.json…")
|
||||
names_src = translated_dir / NAMES_JSON
|
||||
names_drift = allow_code_drift
|
||||
if not names_drift:
|
||||
try:
|
||||
names_doc = json.loads(names_src.read_text(encoding="utf-8-sig"))
|
||||
except Exception:
|
||||
names_doc = None
|
||||
if isinstance(names_doc, dict) and wolf_codes.names_doc_has_font_size_drift(
|
||||
names_doc
|
||||
):
|
||||
names_drift = True
|
||||
emit(
|
||||
" ℹ names-wrap changed \\f[N] sizes — "
|
||||
"passing --allow-code-drift for names-inject"
|
||||
)
|
||||
prep_error = _prepare_for_names_inject(
|
||||
names_src,
|
||||
manifest_entries,
|
||||
data_dir,
|
||||
originals_dir,
|
||||
game_root,
|
||||
log_fn=log_fn,
|
||||
allow_code_drift=names_drift,
|
||||
)
|
||||
if prep_error:
|
||||
result = FileInjectResult(NAMES_JSON, False, prep_error)
|
||||
report.files.append(result)
|
||||
emit(f" ✗ {NAMES_JSON}: {result.summary}")
|
||||
todo = [n for n in todo if n != NAMES_JSON]
|
||||
else:
|
||||
emit(f"Injecting {NAMES_JSON}…")
|
||||
result = _inject_names(
|
||||
names_src,
|
||||
data_dir,
|
||||
allow_code_drift=names_drift,
|
||||
en_punct=en_punct,
|
||||
log_fn=log_fn,
|
||||
)
|
||||
report.files.append(result)
|
||||
emit((" ✓ " if result.success else " ✗ ") + f"{NAMES_JSON}: {result.summary}")
|
||||
|
||||
strings_todo = [n for n in todo if n != NAMES_JSON]
|
||||
if strings_todo:
|
||||
ensure_db_dat_snapshots(manifest_entries, data_dir, originals_dir)
|
||||
|
||||
for json_name in strings_todo:
|
||||
entry = by_json[json_name]
|
||||
inject_src = repair_inject_json(translated_dir / json_name)
|
||||
emit(f"Injecting {json_name}…")
|
||||
result = _inject_strings(
|
||||
json_name,
|
||||
entry,
|
||||
inject_src,
|
||||
data_dir,
|
||||
originals_dir,
|
||||
allow_code_drift=allow_code_drift,
|
||||
en_punct=en_punct,
|
||||
)
|
||||
report.files.append(result)
|
||||
emit((" ✓ " if result.success else " ✗ ") + f"{json_name}: {result.summary}")
|
||||
|
||||
return report
|
||||
|
||||
|
||||
def format_report_dialog(report: InjectReport) -> tuple[str, str]:
|
||||
"""Return (title, body) for a completion dialog."""
|
||||
if report.ok and report.succeeded:
|
||||
title = "Inject complete"
|
||||
lines = [f"✓ {r.json_name}: {r.summary}" for r in report.succeeded]
|
||||
elif report.failed or report.sync_failures:
|
||||
title = "Inject finished with errors"
|
||||
lines = []
|
||||
for r in report.succeeded:
|
||||
lines.append(f"✓ {r.json_name}: {r.summary}")
|
||||
for r in report.failed:
|
||||
lines.append(f"✗ {r.json_name}: {r.summary}")
|
||||
if r.detail:
|
||||
lines.append(f" {r.detail}")
|
||||
for name, err in report.sync_failures:
|
||||
lines.append(f"✗ sync {name}: {err}")
|
||||
else:
|
||||
title = "Inject"
|
||||
lines = ["Nothing was injected."]
|
||||
return title, "\n".join(lines)
|
||||
|
|
@ -6,11 +6,11 @@ a ``note`` field (the WOLF database category it came from), and a static
|
|||
``safety`` badge from WolfDawn's command-stream analysis:
|
||||
|
||||
* ``safe`` - display-only; no command references the string by name.
|
||||
* ``refs`` - referenced by name, but WolfDawn rewrites every literal on inject.
|
||||
* ``refs`` - referenced by name (picture codes, switch labels, etc.); skipped.
|
||||
* ``verify`` - also appears in indirect literals; left untranslated by default.
|
||||
|
||||
:mod:`modules.wolfdawn` translates only entries whose badge is ``safe`` or
|
||||
``refs``. ``verify`` and legacy entries without a badge keep ``text == source``
|
||||
:mod:`modules.wolfdawn` translates only entries whose badge is ``safe``.
|
||||
``refs``, ``verify``, and legacy entries without a badge keep ``text == source``
|
||||
so injection is a no-op for them.
|
||||
|
||||
Phase 0 still harvests translated **short name-like** entries into ``vocab.txt``,
|
||||
|
|
@ -22,12 +22,15 @@ from __future__ import annotations
|
|||
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
from pathlib import Path
|
||||
from typing import Any, Union
|
||||
|
||||
PathLike = Union[str, Path]
|
||||
|
||||
SAFETY_SAFE = "safe"
|
||||
SAFETY_REFS = "refs"
|
||||
SAFETY_VERIFY = "verify"
|
||||
_TRANSLATABLE_SAFETY = frozenset({SAFETY_SAFE, SAFETY_REFS})
|
||||
_TRANSLATABLE_SAFETY = frozenset({SAFETY_SAFE})
|
||||
|
||||
# names.json ``note`` values that are content fields, not short display names.
|
||||
_VOCAB_SKIP_NOTE_RE = re.compile(
|
||||
|
|
@ -77,7 +80,7 @@ def entry_safety(entry: dict[str, Any]) -> str:
|
|||
|
||||
|
||||
def is_name_translatable(entry: dict[str, Any]) -> bool:
|
||||
"""True when WolfDawn marks *entry* as safe or refs (per-name, not per-category)."""
|
||||
"""True when WolfDawn marks *entry* as safe (per-name, not per-category)."""
|
||||
return entry_safety(entry) in _TRANSLATABLE_SAFETY
|
||||
|
||||
|
||||
|
|
@ -127,7 +130,7 @@ def count_name_safety(data: dict[str, Any]) -> dict[str, int]:
|
|||
counts["verify"] += 1
|
||||
else:
|
||||
counts["unknown"] += 1
|
||||
counts["translatable"] = counts["safe"] + counts["refs"]
|
||||
counts["translatable"] = counts["safe"]
|
||||
return counts
|
||||
|
||||
|
||||
|
|
@ -151,7 +154,7 @@ def format_name_safety_summary(data: dict[str, Any]) -> str:
|
|||
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."
|
||||
f"({badge_text}). Refs and verify names are skipped."
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -191,6 +194,32 @@ def note_header(note: str, db_labels: dict[str, str] | None = None) -> str:
|
|||
return note
|
||||
|
||||
|
||||
def format_note_category_summary(
|
||||
data: dict[str, Any],
|
||||
*,
|
||||
top_n: int = 8,
|
||||
db_labels: dict[str, str] | None = None,
|
||||
) -> str:
|
||||
"""Multi-line breakdown of top ``note`` categories for the Names workflow step."""
|
||||
from collections import Counter
|
||||
|
||||
counts: Counter[str] = Counter()
|
||||
for entry in parse_names_entries(data):
|
||||
note = str(entry.get("note", "")).strip()
|
||||
if note:
|
||||
counts[note] += 1
|
||||
if not counts:
|
||||
return "No note categories found."
|
||||
lines = [f"Top categories in this game ({len(counts)} unique):"]
|
||||
for note, count in counts.most_common(top_n):
|
||||
header = note_header(note, db_labels)
|
||||
lines.append(f" {count:4d} {header}")
|
||||
remaining = len(counts) - min(top_n, len(counts))
|
||||
if remaining > 0:
|
||||
lines.append(f" … and {remaining} more categor{'y' if remaining == 1 else 'ies'}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def collect_name_notes(data: dict[str, Any]) -> list[str]:
|
||||
"""Return sorted unique ``note`` values from a names.json document."""
|
||||
notes: set[str] = set()
|
||||
|
|
@ -201,28 +230,149 @@ def collect_name_notes(data: dict[str, Any]) -> list[str]:
|
|||
return sorted(notes)
|
||||
|
||||
|
||||
def parse_name_wrap_notes(raw: str) -> frozenset[str]:
|
||||
"""Parse ``wolfNameWrapNotes`` from ``.env`` (JSON array or comma-separated)."""
|
||||
text = (raw or "").strip()
|
||||
if not text:
|
||||
return frozenset()
|
||||
def apply_names_wrap(
|
||||
names_path: PathLike,
|
||||
*,
|
||||
note: str | None = None,
|
||||
width: int | None = None,
|
||||
lines: int | None = None,
|
||||
dry_run: bool = False,
|
||||
log_fn=None,
|
||||
) -> tuple[bool, str]:
|
||||
"""Run ``wolf names-wrap`` on one names.json file.
|
||||
|
||||
When *note* is set, only that category (``names[].note``) is processed.
|
||||
Omit *width* / *lines* to measure slot geometry from JP text (recommended).
|
||||
|
||||
Returns ``(ok, summary)``. *ok* is False only when the CLI exits with a hard
|
||||
error; overflow warnings still count as success.
|
||||
"""
|
||||
from util import wolfdawn
|
||||
|
||||
emit = log_fn or (lambda _msg: None)
|
||||
path = Path(names_path)
|
||||
if not path.is_file():
|
||||
return False, f"names.json not found: {path}"
|
||||
|
||||
res = wolfdawn.names_wrap(
|
||||
path,
|
||||
note=note,
|
||||
width=width,
|
||||
lines=lines,
|
||||
dry_run=dry_run,
|
||||
log_fn=None,
|
||||
)
|
||||
for line in (res.stdout or "").splitlines():
|
||||
line = line.strip()
|
||||
if line:
|
||||
emit(line)
|
||||
for line in (res.stderr or "").splitlines():
|
||||
line = line.strip()
|
||||
if line:
|
||||
emit(line)
|
||||
|
||||
if not res.ok and res.returncode not in (0, 2):
|
||||
return False, f"names-wrap exited {res.returncode}"
|
||||
|
||||
count = wolfdawn.parse_names_wrap_counts(res.stdout, res.stderr)
|
||||
shrunk = wolfdawn.parse_names_wrap_shrink_count(res.stdout, res.stderr)
|
||||
scope = f"category {note}" if note else "names.json"
|
||||
parts: list[str] = []
|
||||
if dry_run:
|
||||
if count is not None:
|
||||
parts.append(
|
||||
f"dry run: {count} entr{'y' if count == 1 else 'ies'} in {scope} "
|
||||
"would be re-wrapped"
|
||||
)
|
||||
else:
|
||||
parts.append(f"dry run complete for {scope}")
|
||||
elif count is not None:
|
||||
parts.append(
|
||||
f"re-wrapped {count} entr{'y' if count == 1 else 'ies'} in {scope}"
|
||||
)
|
||||
else:
|
||||
parts.append(f"names-wrap complete for {scope}")
|
||||
if width is not None and int(width) > 0:
|
||||
parts.append(f"width={int(width)}")
|
||||
if lines is not None and int(lines) > 0:
|
||||
parts.append(f"lines={int(lines)}")
|
||||
if shrunk:
|
||||
parts.append(f"{shrunk} shrunk with \\f[N]")
|
||||
return True, "; ".join(parts)
|
||||
|
||||
|
||||
def roles_json_path_for_names(names_path: PathLike) -> Path:
|
||||
"""Return ``wolfdawn-roles.json`` beside *names_path* (WolfDawn looks there)."""
|
||||
return Path(names_path).resolve().parent / "wolfdawn-roles.json"
|
||||
|
||||
|
||||
def load_name_wrap_roles(roles_path: PathLike) -> dict[str, Any]:
|
||||
"""Load ``wolfdawn-roles.json``, or an empty document."""
|
||||
path = Path(roles_path)
|
||||
if not path.is_file():
|
||||
return {}
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
if isinstance(parsed, list):
|
||||
return frozenset(str(item).strip() for item in parsed if str(item).strip())
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return frozenset(part.strip() for part in text.split(",") if part.strip())
|
||||
data = json.loads(path.read_text(encoding="utf-8-sig"))
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def load_name_wrap_config() -> tuple[bool, int, frozenset[str]]:
|
||||
"""Return ``(enabled, width, notes)`` from workflow ``.env`` settings."""
|
||||
import os
|
||||
def get_name_wrap_role(roles: dict[str, Any], note: str) -> dict[str, Any] | None:
|
||||
"""Return the ``nameWraps`` entry matching *note*, if any."""
|
||||
wraps = roles.get("nameWraps")
|
||||
if not isinstance(wraps, list):
|
||||
return None
|
||||
for entry in wraps:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
entry_note = str(entry.get("note") or "")
|
||||
if entry_note == note or (note and note in entry_note) or (entry_note and entry_note in note):
|
||||
return entry
|
||||
return None
|
||||
|
||||
enabled = os.getenv("wolfNameWrap", "false").strip().lower() == "true"
|
||||
try:
|
||||
width = int(os.getenv("wolfNameWrapWidth") or 0)
|
||||
except ValueError:
|
||||
width = 0
|
||||
notes = parse_name_wrap_notes(os.getenv("wolfNameWrapNotes", ""))
|
||||
return enabled, width, notes
|
||||
|
||||
def upsert_name_wrap_role(
|
||||
roles_path: PathLike,
|
||||
note: str,
|
||||
*,
|
||||
width: int | None = None,
|
||||
max_lines: int | None = None,
|
||||
font: int | None = None,
|
||||
min_font: int | None = None,
|
||||
) -> Path:
|
||||
"""Create/update a ``nameWraps`` rule for *note* in ``wolfdawn-roles.json``.
|
||||
|
||||
WolfDawn reads this file from the same directory as ``names.json``.
|
||||
"""
|
||||
path = Path(roles_path)
|
||||
data = load_name_wrap_roles(path)
|
||||
wraps = data.get("nameWraps")
|
||||
if not isinstance(wraps, list):
|
||||
wraps = []
|
||||
data["nameWraps"] = wraps
|
||||
|
||||
entry = None
|
||||
for item in wraps:
|
||||
if isinstance(item, dict) and str(item.get("note") or "") == note:
|
||||
entry = item
|
||||
break
|
||||
if entry is None:
|
||||
entry = {"note": note}
|
||||
wraps.append(entry)
|
||||
|
||||
if width is not None and int(width) > 0:
|
||||
entry["width"] = int(width)
|
||||
if max_lines is not None and int(max_lines) > 0:
|
||||
entry["maxLines"] = int(max_lines)
|
||||
if font is not None and int(font) > 0:
|
||||
entry["font"] = int(font)
|
||||
if min_font is not None and int(min_font) > 0:
|
||||
entry["minFont"] = int(min_font)
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
json.dumps(data, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return path
|
||||
|
|
|
|||
88
util/wolfdawn/originals.py
Normal file
88
util/wolfdawn/originals.py
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
"""Pristine binary snapshots for idempotent WolfDawn inject."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from util import wolfdawn
|
||||
|
||||
ProgressFn = Callable[[int, int, str], None]
|
||||
|
||||
|
||||
def find_data_archives(game_root: Path, data_dir: Path) -> list[Path]:
|
||||
"""Return MapData / BasicData ``.wolf`` or ``.wolf.bak`` archives."""
|
||||
archives: list[Path] = []
|
||||
for base in (game_root, data_dir):
|
||||
if not base.is_dir():
|
||||
continue
|
||||
for pat in ("*.wolf", "*.wolf.bak"):
|
||||
archives.extend(base.glob(pat))
|
||||
return [a for a in archives if a.name.lower().startswith(("mapdata", "basicdata"))]
|
||||
|
||||
|
||||
def rebuild_originals_from_archives(
|
||||
game_root: Path,
|
||||
originals_dir: Path,
|
||||
*,
|
||||
force: bool = False,
|
||||
log_fn: Callable[[str], None] | None = None,
|
||||
progress_fn: ProgressFn | None = None,
|
||||
) -> bool:
|
||||
"""Unpack ``BasicData`` / ``MapData`` archives into *originals_dir*.
|
||||
|
||||
When *force* is true, the entire *originals_dir* tree is removed first so
|
||||
stale flat snapshots from an old extract cannot survive beside rebuilt data.
|
||||
"""
|
||||
emit = log_fn or (lambda _msg: None)
|
||||
data_dir = game_root / "Data"
|
||||
archives = find_data_archives(game_root, data_dir)
|
||||
if not archives:
|
||||
emit(" ⚠ no BasicData/MapData .wolf archives found to rebuild originals")
|
||||
return False
|
||||
|
||||
if force and originals_dir.exists():
|
||||
shutil.rmtree(originals_dir)
|
||||
originals_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
emit("Rebuilding pristine originals from the game's .wolf archives…")
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
inputs: list[str] = []
|
||||
for arc in archives:
|
||||
if arc.suffix == ".bak":
|
||||
staged = Path(tmp) / arc.with_suffix("").name
|
||||
shutil.copy2(arc, staged)
|
||||
inputs.append(str(staged))
|
||||
else:
|
||||
inputs.append(str(arc))
|
||||
res = wolfdawn.unpack_all(
|
||||
inputs,
|
||||
str(originals_dir),
|
||||
log_fn=log_fn,
|
||||
progress_fn=progress_fn,
|
||||
progress_total=len(inputs),
|
||||
)
|
||||
if not res.ok:
|
||||
emit(f" ⚠ could not rebuild originals (unpack exit {res.returncode})")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def names_inject_would_apply(
|
||||
names_json: Path,
|
||||
data_dir: Path,
|
||||
*,
|
||||
allow_code_drift: bool = False,
|
||||
) -> int | None:
|
||||
"""Return how many name changes wolf would apply (dry run), or None if unknown."""
|
||||
res = wolfdawn.names_inject(
|
||||
str(names_json),
|
||||
str(data_dir),
|
||||
dry_run=True,
|
||||
allow_code_drift=allow_code_drift,
|
||||
log_fn=None,
|
||||
)
|
||||
applied, _drifted = wolfdawn.parse_names_inject_counts(res.stdout, res.stderr)
|
||||
return applied
|
||||
218
util/wolfdawn/selective_wrap.py
Normal file
218
util/wolfdawn/selective_wrap.py
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
"""Selective word-wrap for WolfDawn translated JSON.
|
||||
|
||||
Bulk ``wolf relayout`` / ``desc-relayout`` cannot target individual DB rows or
|
||||
custom field types (e.g. ``セリフ_メッセージ``, ``好きなもの_コメント``).
|
||||
This module wraps ``text`` fields in ``translated/*.project.json`` for checked
|
||||
database sheets and field patterns, then the user re-injects only those files.
|
||||
|
||||
``wolf relayout`` for maps already skips messages that fit; use it for dialogue
|
||||
overflow. Use selective JSON wrap for custom DB columns and fine-grained control.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import util.dazedwrap as dazedwrap
|
||||
from util.wolfdawn.db_classify import group_key, json_file_from_doc
|
||||
|
||||
# Field-name presets for the Relayout tab (match ``fieldName`` substrings).
|
||||
FIELD_PRESET_DESCRIPTIONS = re.compile(r"説明|Description", re.IGNORECASE)
|
||||
FIELD_PRESET_COMMENTS = re.compile(r"コメント")
|
||||
FIELD_PRESET_DIALOGUE = re.compile(r"セリフ|台詞|会話")
|
||||
FIELD_PRESET_MESSAGES = re.compile(r"メッセージ")
|
||||
FIELD_PRESET_ALL = re.compile(r".", re.DOTALL)
|
||||
|
||||
FIELD_PRESETS: dict[str, re.Pattern[str]] = {
|
||||
"descriptions": FIELD_PRESET_DESCRIPTIONS,
|
||||
"comments": FIELD_PRESET_COMMENTS,
|
||||
"dialogue": FIELD_PRESET_DIALOGUE,
|
||||
"messages": FIELD_PRESET_MESSAGES,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class WrapResult:
|
||||
files_touched: int = 0
|
||||
lines_wrapped: int = 0
|
||||
lines_skipped: int = 0
|
||||
touched_json: list[str] | None = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.touched_json is None:
|
||||
self.touched_json = []
|
||||
|
||||
|
||||
def combine_field_patterns(enabled: frozenset[str]) -> re.Pattern[str] | None:
|
||||
"""Merge enabled preset keys into one regex, or None when nothing selected."""
|
||||
if not enabled:
|
||||
return None
|
||||
parts = []
|
||||
for key in enabled:
|
||||
pat = FIELD_PRESETS.get(key)
|
||||
if pat is not None and key != "all":
|
||||
parts.append(f"(?:{pat.pattern})")
|
||||
if "all" in enabled:
|
||||
return FIELD_PRESET_ALL
|
||||
if not parts:
|
||||
return None
|
||||
return re.compile("|".join(parts), re.IGNORECASE)
|
||||
|
||||
|
||||
def _visible_length(text: str) -> int:
|
||||
return dazedwrap._get_visible_length(text)
|
||||
|
||||
|
||||
def line_needs_wrap(text: str, width: int, *, min_visible: int = 0) -> bool:
|
||||
"""True when *text* is long enough and exceeds *width* on at least one line."""
|
||||
if not isinstance(text, str) or not text.strip() or width <= 0:
|
||||
return False
|
||||
lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
||||
if not lines:
|
||||
visible = _visible_length(text)
|
||||
if min_visible > 0 and visible < min_visible:
|
||||
return False
|
||||
return visible > width
|
||||
total_visible = max((_visible_length(line) for line in lines), default=0)
|
||||
if min_visible > 0 and total_visible < min_visible:
|
||||
return False
|
||||
return any(_visible_length(line) > width for line in lines)
|
||||
|
||||
|
||||
def wrap_line_text(text: str, width: int) -> str:
|
||||
"""Word-wrap one ``text`` value, preserving WOLF inline codes.
|
||||
|
||||
Width is measured with :func:`util.dazedwrap._get_visible_length`, so
|
||||
``\\c[N]``, ``\\f[N]``, and similar codes do not consume box width.
|
||||
"""
|
||||
if not isinstance(text, str) or not text.strip() or width <= 0:
|
||||
return text
|
||||
return dazedwrap.wrapText(text, width)
|
||||
|
||||
|
||||
def group_type_indices(doc: dict[str, Any], group_keys: frozenset[str]) -> list[int]:
|
||||
"""Return sorted DB ``type`` indices for selected sheet keys."""
|
||||
jf = json_file_from_doc(doc)
|
||||
indices: set[int] = set()
|
||||
for group in doc.get("groups") or []:
|
||||
type_name = str(group.get("typeName") or "")
|
||||
if group_key(jf, type_name) not in group_keys:
|
||||
continue
|
||||
try:
|
||||
idx = int(group.get("type", -1))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if idx >= 0:
|
||||
indices.add(idx)
|
||||
return sorted(indices)
|
||||
|
||||
|
||||
def wrap_db_document(
|
||||
doc: dict[str, Any],
|
||||
*,
|
||||
group_keys: frozenset[str],
|
||||
field_pattern: re.Pattern[str] | None,
|
||||
width: int,
|
||||
min_visible: int = 0,
|
||||
only_if_overflow: bool = True,
|
||||
) -> tuple[int, int]:
|
||||
"""Wrap matching lines in one ``kind: db`` document. Returns (wrapped, skipped)."""
|
||||
if doc.get("kind") != "db" or not group_keys:
|
||||
return 0, 0
|
||||
jf = json_file_from_doc(doc)
|
||||
wrapped = skipped = 0
|
||||
for group in doc.get("groups") or []:
|
||||
type_name = str(group.get("typeName") or "")
|
||||
if group_key(jf, type_name) not in group_keys:
|
||||
continue
|
||||
for line in group.get("lines") or []:
|
||||
field_name = str(line.get("fieldName") or "")
|
||||
if field_pattern is not None and not field_pattern.search(field_name):
|
||||
skipped += 1
|
||||
continue
|
||||
text = line.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
skipped += 1
|
||||
continue
|
||||
if only_if_overflow and not line_needs_wrap(
|
||||
text, width, min_visible=min_visible
|
||||
):
|
||||
skipped += 1
|
||||
continue
|
||||
new_text = wrap_line_text(text, width)
|
||||
if new_text != text:
|
||||
line["text"] = new_text
|
||||
wrapped += 1
|
||||
else:
|
||||
skipped += 1
|
||||
return wrapped, skipped
|
||||
|
||||
|
||||
def wrap_db_translated_dir(
|
||||
translated_dir: str | Path,
|
||||
*,
|
||||
group_keys: frozenset[str],
|
||||
field_presets: frozenset[str],
|
||||
width: int,
|
||||
min_visible: int = 0,
|
||||
only_if_overflow: bool = True,
|
||||
) -> WrapResult:
|
||||
"""Apply selective wrap to all ``*.project.json`` under ``translated/``."""
|
||||
base = Path(translated_dir)
|
||||
result = WrapResult()
|
||||
if not base.is_dir() or not group_keys:
|
||||
return result
|
||||
field_pattern = combine_field_patterns(field_presets)
|
||||
if field_pattern is None:
|
||||
return result
|
||||
|
||||
for path in sorted(base.glob("*.project.json")):
|
||||
try:
|
||||
doc = json.loads(path.read_text(encoding="utf-8-sig"))
|
||||
except Exception:
|
||||
continue
|
||||
if doc.get("kind") != "db":
|
||||
continue
|
||||
w, s = wrap_db_document(
|
||||
doc,
|
||||
group_keys=group_keys,
|
||||
field_pattern=field_pattern,
|
||||
width=width,
|
||||
min_visible=min_visible,
|
||||
only_if_overflow=only_if_overflow,
|
||||
)
|
||||
if w:
|
||||
path.write_text(
|
||||
json.dumps(doc, ensure_ascii=False, indent=4) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
result.files_touched += 1
|
||||
result.lines_wrapped += w
|
||||
result.touched_json.append(path.name)
|
||||
result.lines_skipped += s
|
||||
return result
|
||||
|
||||
|
||||
def collect_field_names(translated_dir: str | Path) -> list[str]:
|
||||
"""Sorted unique ``fieldName`` values from staged/translated DB JSON."""
|
||||
base = Path(translated_dir)
|
||||
names: set[str] = set()
|
||||
if not base.is_dir():
|
||||
return []
|
||||
for path in base.glob("*.project.json"):
|
||||
try:
|
||||
doc = json.loads(path.read_text(encoding="utf-8-sig"))
|
||||
except Exception:
|
||||
continue
|
||||
if doc.get("kind") != "db":
|
||||
continue
|
||||
for group in doc.get("groups") or []:
|
||||
for line in group.get("lines") or []:
|
||||
fn = str(line.get("fieldName") or "").strip()
|
||||
if fn:
|
||||
names.add(fn)
|
||||
return sorted(names)
|
||||
1176
util/wolfdawn/wrap_search.py
Normal file
1176
util/wolfdawn/wrap_search.py
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue