Compare commits

...

14 commits

Author SHA1 Message Date
ef055cbb33 Working Wrap 2026-07-09 07:24:09 -05:00
a2411b3e31 Working Wrap 2026-07-09 07:09:23 -05:00
dd048f48f1 dawn binaries 2026-07-09 06:18:37 -05:00
7079efc639 Simplify Fix Wrap 2026-07-08 18:42:46 -05:00
2b4a0e257f font and width 2026-07-08 18:25:01 -05:00
0a79b74703 Wolf Dawn Binaries Updatae 2026-07-08 18:12:57 -05:00
3de5368817 Stabilize injects 2026-07-08 18:11:04 -05:00
6badfcb246 Fix Wrap 2026-07-08 17:42:25 -05:00
42cda41458 fix wrap tab 2026-07-08 17:14:06 -05:00
429ad43918 Break DB out 2026-07-08 16:17:34 -05:00
ee21f1f862 Better logs 2026-07-08 15:28:14 -05:00
979ed019d3 fix selected 2026-07-08 15:00:28 -05:00
4c2c7e3c4a names.json not injecting 2026-07-08 14:27:49 -05:00
119981dee1 idk why this was omitted 2026-07-08 14:21:16 -05:00
25 changed files with 5733 additions and 991 deletions

View file

@ -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=","

View file

@ -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 34 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.

View file

@ -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

View file

@ -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

View file

@ -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()

View 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()

View 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()

View file

@ -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()

View file

@ -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()

View 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()

View 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()

View file

@ -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:

View file

@ -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)

View file

@ -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:
"""

View file

@ -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.

View file

@ -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."""

View 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
View 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)

View file

@ -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

View 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

View 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

File diff suppressed because it is too large Load diff