Wolf support for skills prep

This commit is contained in:
DazedAnon 2026-07-11 12:33:29 -05:00
parent b91d5b9acd
commit 26132a02aa
4 changed files with 769 additions and 626 deletions

View file

@ -123,9 +123,93 @@ Do **not** include:
<!-- engine:wolf -->
### Wolf file strategy (stub)
### Wolf file strategy
When analysing a WolfDawn / WOLF project later: use `wolf_json/` extracts (`names.json`, DB sheets, map/common scenes). Prefer foundation DB + narrative sheets; grep large map dumps. Same four-block ownership rules apply.
WolfDawn extractions live under `files/` as JSON lists of `{source, text}` entries.
Analyse `source` only.
**DB / system (read in full first):**
- `DataBase.project.json`, `CDataBase.project.json`, `SysDatabase.project.json` - richest small source of character/actor names, classes, factions, lore titles
- `CommonEvent.dat.json` - common events (dialogue + system text)
- `Game.dat.json` - game/system strings (title, terms)
- `Evtext.json` - external event text when present
**Maps (grep / sample, do not read huge files sequentially):**
- `<Map>.mps.json` - per-map events; main story dialogue; often very large
- Prefer grep of `source` for speaker patterns and recurring proper nouns
- Scan lowest-numbered maps first - early maps usually carry the most story
**Exclude from glossary analysis:**
- `names.json` - item / skill / enemy value names (translated separately in Step 3 Names; do **not** list them in the glossary)
--- attach the extracted JSON in files/ here before continuing ---
### Speakers analysis (for `speakers` block)
WolfDawn already tags who speaks. High-confidence nameplates (`literal_line1`) are always reshaped - nothing to decide.
The only flag is whether **low-confidence** first-line guesses (`speaker_src = literal_line1_lowconf`) are real speaker names for this game:
- short first line with **no** preceding face window
- might be a nameplate, or the start of dialogue / narration
Inspect a sample of those entries in maps / CommonEvent:
- `ENABLE` if low-confidence first lines are overwhelmingly real speaker names
- `SKIP` if many are dialogue or narration (reshaping would mislabel lines)
Do **not** emit RPG Maker flags (`INLINE401SPEAKERS`, `FIRSTLINESPEAKERS`, `FACENAME101`).
**Wolf `speakers` schema (use this instead of the shared RPG Maker flag list):**
```
Patterns detected:
- ...
Flag decisions:
LOWCONF_FIRSTLINE : ENABLE|SKIP - <one-line reason>
Examples:
- ...
```
### Glossary rules (for `glossary` block)
- Separator: plain hyphen-minus `-` only (never em/en dash).
- Descriptions entirely in English; refer to other characters by English name.
- Commit to one spelling - never `Sylfia / Sylphia`.
- Characters: gender, role, speech register, personality; note player-chosen names (variable / input).
- Worldbuilding: factions, lore locations (mentioned in dialogue but not map labels), unique systems/titles.
- Exclude: skill / item / weapon / armour names from `names.json`, generic RPG words, unnamed NPCs.
### Quirks rules (for `translation_quirks` block)
Find translation-only quirks, for example:
- Battle log / system messages with a fixed person or register
- Global dialect (archaic narration, cute speech markers game-wide)
- Recurring item/skill description style
- Unusual honorific habits
Exclude: wrap geometry, inject layout, `names.json` harvest, speaker LOWCONF checkbox, character name lists.
Output as short imperative bullets suitable to paste into `skills/quirks.md`.
### Game skill rules (for `game_skill` block)
Produce the per-game translation skill saved at `skills/game.md`.
DazedMTLTool **merges this file into the translation system prompt** (before quirks).
**Translation Frame only** (one compact line per field; evidence-based):
- `世界観 (Theme / setting)` - genre, world type, core atmosphere
- `時代感 (Era / technology level)` - medieval / modern / sci-fi / historical / etc.
- `文体方針 (Register policy)` - overall English style
- `固有名詞方針 (Naming policy)` - invented names, titles, ranks, honorifics (high-level only)
- `神話・伝承 (Myth / folklore basis)` - **omit unless** evidence supports a specific tradition
Do **not** include voice-rules pointers, tool-boundary essays, file inventories, per-character register (glossary), or quirks bullets.
**Paths:**
- Game skill (API): ``skills/game.md``
- Quirks (API): ``skills/quirks.md``
- Optional custom API overlays: other ``skills/*.md`` except those two
<!-- /engine:wolf -->

587
gui/setup_skills_editors.py Normal file
View file

@ -0,0 +1,587 @@
"""Shared Vocab / Quirks / Game skills editors for Workflow Setup steps."""
from __future__ import annotations
from collections.abc import Callable
from PyQt5.QtCore import Qt, QSize
from PyQt5.QtGui import QFont
from PyQt5.QtWidgets import (
QHBoxLayout,
QInputDialog,
QLabel,
QMessageBox,
QPushButton,
QStackedWidget,
QTabBar,
QTabWidget,
QTextEdit,
QVBoxLayout,
QWidget,
)
from util.skills import (
custom_skill_path_for_game,
game_skill_path_for_game,
list_custom_skill_paths,
migrate_game_skill_text,
quirks_path_for_game,
sanitize_custom_skill_stem,
)
from util.vocab import read_game_vocab, write_game_vocab
class _PlainPasteTextEdit(QTextEdit):
"""QTextEdit that always pastes as plain text."""
def insertFromMimeData(self, source): # noqa: N802
self.insertPlainText(source.text())
def _make_btn(text: str, color: str = "#007acc") -> QPushButton:
"""Match workflow_tab button styling (flat dark + coloured outline)."""
btn = QPushButton()
try:
c = color.lstrip("#")
if len(c) == 3:
c = c[0] * 2 + c[1] * 2 + c[2] * 2
r, g, b = int(c[0:2], 16), int(c[2:4], 16), int(c[4:6], 16)
is_flat = max(r, g, b) < 115
except Exception:
r = g = b = 0
is_flat = False
_PAD = "padding:6px 14px;"
_icon_color = "#cccccc"
if is_flat:
btn.setStyleSheet(
f"QPushButton{{background-color:#2d2d30;color:#cccccc;"
f"border:1px solid #555555;{_PAD}"
f"border-radius:4px;font-size:12px;font-weight:bold;"
f"font-family:'Segoe UI','Segoe UI Emoji','Apple Color Emoji',sans-serif;}}"
f"QPushButton:hover{{background-color:#3e3e42;}}"
f"QPushButton:pressed{{background-color:#007acc;color:white;}}"
f"QPushButton:disabled{{background-color:#404040;color:#666666;border-color:#444444;}}"
)
else:
rt = min(255, r + 80)
gt = min(255, g + 80)
bt = min(255, b + 80)
text_color = f"#{rt:02x}{gt:02x}{bt:02x}"
_icon_color = text_color
base = 0x2d
rh = min(255, int(base + (r - base) * 0.18))
gh = min(255, int(base + (g - base) * 0.18))
bh = min(255, int(base + (b - base) * 0.18))
hover_bg = f"#{rh:02x}{gh:02x}{bh:02x}"
rb = min(255, r + 35)
gb = min(255, g + 35)
bb = min(255, b + 35)
hover_accent = f"#{rb:02x}{gb:02x}{bb:02x}"
btn.setStyleSheet(
f"QPushButton{{background-color:#2d2d30;color:{text_color};"
f"border:1px solid {color};{_PAD}"
f"border-radius:4px;font-size:12px;font-weight:bold;"
f"font-family:'Segoe UI','Segoe UI Emoji','Apple Color Emoji',sans-serif;}}"
f"QPushButton:hover{{background-color:{hover_bg};border-color:{hover_accent};"
f"color:{hover_accent};}}"
f"QPushButton:pressed{{background-color:#1a1a1a;}}"
f"QPushButton:disabled{{background-color:#2d2d30;color:#555555;border-color:#444444;}}"
)
from gui import qt_icons
qt_icons.apply_button_icon(btn, text, color=_icon_color)
if not btn.icon().isNull():
btn.setIconSize(QSize(16, 16))
return btn
class SetupSkillsEditors(QWidget):
"""Tabbed Vocab / Quirks / Game skills editors for Workflow Setup.
Parameters
----------
game_root_fn:
Callable returning the current game root path (may be empty).
log_fn:
Callable used for status / error messages.
"""
def __init__(
self,
parent: QWidget | None = None,
*,
game_root_fn: Callable[[], str],
log_fn: Callable[[str], None],
):
super().__init__(parent)
self._game_root_fn = game_root_fn
self._log = log_fn
self._custom_skill_editors: dict[str, QTextEdit] = {}
self.vocab_editor: QTextEdit | None = None
self.quirks_editor: QTextEdit | None = None
self.game_skill_editor: QTextEdit | None = None
self._build_ui()
def _build_ui(self) -> None:
root = QVBoxLayout(self)
root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(0)
editors = QTabWidget()
editors.setObjectName("setupEditors")
editors.setDocumentMode(False)
tab_bar = editors.tabBar()
tab_bar.setExpanding(False)
tab_bar.setMinimumHeight(34)
tab_bar.setStyleSheet(
"QTabBar::tab{background:#333337;color:#c0c0c0;min-width:90px;min-height:28px;"
"padding:6px 16px;border:1px solid #3c3c3c;border-bottom:none;"
"margin-right:3px;font-size:12px;font-weight:bold;}"
"QTabBar::tab:selected{background:#1e1e1e;color:#4ec9b0;"
"border-top:2px solid #4ec9b0;}"
"QTabBar::tab:hover{color:#ffffff;}"
)
editors.setStyleSheet(
"QTabWidget::pane{border:1px solid #3c3c3c;background:#1e1e1e;}"
)
editors.addTab(
self._editor_page(
"data/vocab.txt (game section)",
"Paste the glossary code block. Format: Japanese (English) - notes",
self._save_vocab,
self._reload_vocab,
"vocab_editor",
),
"Vocab",
)
editors.addTab(
self._editor_page(
"<game>/skills/quirks.md",
"Paste the translation_quirks block. Merged onto the system prompt at translate time.",
self._save_quirks,
self._reload_quirks,
"quirks_editor",
),
"Quirks",
)
game_skills_page = QWidget()
gs_layout = QVBoxLayout(game_skills_page)
gs_layout.setContentsMargins(0, 0, 0, 0)
gs_layout.setSpacing(0)
self._game_skills_bar = QTabBar()
self._game_skills_bar.setDocumentMode(True)
self._game_skills_bar.setExpanding(False)
self._game_skills_bar.setDrawBase(False)
self._game_skills_bar.setMinimumHeight(30)
self._game_skills_bar.setStyleSheet(
"QTabBar::tab{background:#2d2d30;color:#9d9d9d;padding:6px 12px;"
"border:1px solid #3c3c3c;border-bottom:none;margin-right:2px;}"
"QTabBar::tab:selected{background:#1e1e1e;color:#e0e0e0;}"
"QTabBar::tab:hover{color:#ffffff;}"
)
self._game_skills_stack = QStackedWidget()
self._game_skills_stack.setStyleSheet("background:#1e1e1e;")
self._game_skills_bar.currentChanged.connect(
self._game_skills_stack.setCurrentIndex
)
strip = QWidget()
strip.setStyleSheet("background-color:#252526;")
strip_l = QHBoxLayout(strip)
strip_l.setContentsMargins(8, 6, 10, 0)
strip_l.setSpacing(6)
strip_l.addWidget(self._game_skills_bar, 0, Qt.AlignBottom)
add_btn = QPushButton("+ Add custom")
add_btn.setCursor(Qt.PointingHandCursor)
add_btn.setFixedHeight(30)
add_btn.setToolTip(
"Create a custom skills/*.md overlay.\n"
"Merged into the translation system prompt - can hurt quality."
)
add_btn.setStyleSheet(
"QPushButton{background-color:#2d2d30;color:#4ec9b0;"
"border:1px solid #3c3c3c;border-bottom:none;"
"border-top-left-radius:3px;border-top-right-radius:3px;"
"padding:0 12px;font-size:12px;font-weight:bold;}"
"QPushButton:hover{background-color:#3a3a3a;color:#7ee0c8;"
"border-color:#4ec9b0;}"
"QPushButton:pressed{background-color:#1e1e1e;}"
)
add_btn.clicked.connect(self._add_custom_skill)
strip_l.addWidget(add_btn, 0, Qt.AlignBottom)
strip_l.addStretch(1)
gs_layout.addWidget(strip)
gs_layout.addWidget(self._game_skills_stack, 1)
self._add_game_skill_page(
"game",
self._editor_page(
"<game>/skills/game.md",
"Paste the game_skill Translation Frame. Merged into the translation "
"system prompt (before quirks) when this game folder is selected.",
self._save_game_skill,
self._reload_game_skill,
"game_skill_editor",
),
)
editors.addTab(game_skills_page, "Game skills")
root.addWidget(editors, 1)
def _editor_page(
self,
path_hint: str,
tip: str,
save_slot,
reload_slot,
attr: str,
) -> QWidget:
page = QWidget()
vl = QVBoxLayout(page)
vl.setContentsMargins(8, 8, 8, 8)
vl.setSpacing(6)
tip_lbl = QLabel(tip)
tip_lbl.setWordWrap(True)
tip_lbl.setStyleSheet("color:#9d9d9d;font-size:12px;")
vl.addWidget(tip_lbl)
path_lbl = QLabel(path_hint)
path_lbl.setStyleSheet(
"color:#569cd6;font-size:11px;font-family:Consolas,monospace;"
)
vl.addWidget(path_lbl)
ed = _PlainPasteTextEdit()
ed.setMinimumHeight(160)
ed.setFont(QFont("Consolas", 9))
ed.setStyleSheet(
"QTextEdit{background-color:#1e1e1e;color:#d4d4d4;"
"border:none;padding:8px;"
"selection-background-color:#264f78;}"
)
setattr(self, attr, ed)
vl.addWidget(ed, 1)
row = QHBoxLayout()
row.setSpacing(8)
save_btn = _make_btn("💾 Save", "#3a7a3a")
save_btn.setFixedWidth(110)
save_btn.clicked.connect(save_slot)
row.addWidget(save_btn)
reload_btn = _make_btn("↺ Reload", "#555")
reload_btn.setFixedWidth(110)
reload_btn.clicked.connect(reload_slot)
row.addWidget(reload_btn)
row.addStretch()
vl.addLayout(row)
return page
def reload_all(self) -> None:
"""Reload vocab, quirks, game skill, and custom skill tabs from disk."""
self._reload_vocab()
self._reload_quirks()
self._reload_game_skill()
self._reload_custom_skills()
def _game_root(self) -> str:
return (self._game_root_fn() or "").strip()
def _game_root_or_warn(self) -> str | None:
root = self._game_root()
if not root:
self._log("⚠ Select a game folder in Step 0 first.")
return None
return root
def _reload_vocab(self) -> None:
if self.vocab_editor is None:
return
try:
self.vocab_editor.setPlainText(read_game_vocab())
except Exception as exc:
self._log(f"❌ Could not load vocab.txt: {exc}")
def _save_vocab(self) -> None:
if self.vocab_editor is None:
return
try:
write_game_vocab(self.vocab_editor.toPlainText())
self._log("✅ vocab.txt saved (base terms from vocab_base.txt appended).")
except Exception as exc:
self._log(f"❌ Could not save vocab.txt: {exc}")
def _reload_quirks(self) -> None:
if self.quirks_editor is None:
return
path = quirks_path_for_game(self._game_root())
if not path or not path.is_file():
self.quirks_editor.setPlainText("")
return
try:
self.quirks_editor.setPlainText(path.read_text(encoding="utf-8"))
except Exception as exc:
self._log(f"❌ Could not load skills/quirks.md: {exc}")
def _save_quirks(self) -> None:
root = self._game_root_or_warn()
if not root or self.quirks_editor is None:
return
path = quirks_path_for_game(root)
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
self.quirks_editor.toPlainText().rstrip() + "\n", encoding="utf-8"
)
self._log(f"✅ Saved {path}")
except Exception as exc:
self._log(f"❌ Could not save skills/quirks.md: {exc}")
def _reload_game_skill(self) -> None:
if self.game_skill_editor is None:
return
path = game_skill_path_for_game(self._game_root())
if not path or not path.is_file():
self.game_skill_editor.setPlainText("")
return
try:
raw = path.read_text(encoding="utf-8")
fixed = migrate_game_skill_text(raw)
self.game_skill_editor.setPlainText(fixed)
if fixed != raw:
path.write_text(fixed.rstrip() + "\n", encoding="utf-8")
self._log(
f"↺ Updated legacy sections in {path.name} for API use"
)
except Exception as exc:
self._log(f"❌ Could not load game skill: {exc}")
def _save_game_skill(self) -> None:
root = self._game_root_or_warn()
if not root or self.game_skill_editor is None:
return
path = game_skill_path_for_game(root)
try:
path.parent.mkdir(parents=True, exist_ok=True)
text = migrate_game_skill_text(self.game_skill_editor.toPlainText())
self.game_skill_editor.setPlainText(text)
path.write_text(text.rstrip() + "\n", encoding="utf-8")
self._log(f"✅ Saved {path}")
except Exception as exc:
self._log(f"❌ Could not save game skill: {exc}")
def _add_game_skill_page(self, title: str, page: QWidget) -> int:
idx = self._game_skills_stack.addWidget(page)
self._game_skills_bar.addTab(title)
return idx
def _select_game_skill_tab(self, title: str) -> None:
for i in range(self._game_skills_bar.count()):
if self._game_skills_bar.tabText(i) == title:
self._game_skills_bar.setCurrentIndex(i)
return
def _custom_skill_editor_page(self, stem: str) -> QWidget:
page = QWidget()
vl = QVBoxLayout(page)
vl.setContentsMargins(8, 8, 8, 8)
vl.setSpacing(6)
tip = QLabel(
"Custom skill - merged into the translation system prompt. "
"Extra or conflicting rules can hurt quality."
)
tip.setWordWrap(True)
tip.setStyleSheet("color:#c9a227;font-size:12px;")
vl.addWidget(tip)
path_lbl = QLabel(f"<game>/skills/{stem}.md")
path_lbl.setStyleSheet(
"color:#569cd6;font-size:11px;font-family:Consolas,monospace;"
)
vl.addWidget(path_lbl)
ed = _PlainPasteTextEdit()
ed.setMinimumHeight(160)
ed.setFont(QFont("Consolas", 9))
ed.setStyleSheet(
"QTextEdit{background-color:#1e1e1e;color:#d4d4d4;"
"border:none;padding:8px;"
"selection-background-color:#264f78;}"
)
self._custom_skill_editors[stem] = ed
vl.addWidget(ed, 1)
row = QHBoxLayout()
row.setSpacing(8)
save_btn = _make_btn("💾 Save", "#3a7a3a")
save_btn.setFixedWidth(110)
save_btn.clicked.connect(lambda _=False, s=stem: self._save_custom_skill(s))
row.addWidget(save_btn)
reload_btn = _make_btn("↺ Reload", "#555")
reload_btn.setFixedWidth(110)
reload_btn.clicked.connect(
lambda _=False, s=stem: self._reload_one_custom_skill(s)
)
row.addWidget(reload_btn)
delete_btn = _make_btn("🗑 Delete", "#8b0000")
delete_btn.setFixedWidth(110)
delete_btn.setToolTip("Delete this custom skill file from the game folder")
delete_btn.clicked.connect(lambda _=False, s=stem: self._delete_custom_skill(s))
row.addWidget(delete_btn)
row.addStretch()
vl.addLayout(row)
return page
def _reload_custom_skills(self) -> None:
bar = getattr(self, "_game_skills_bar", None)
stack = getattr(self, "_game_skills_stack", None)
if bar is None or stack is None:
return
while bar.count() > 1:
bar.removeTab(1)
while stack.count() > 1:
w = stack.widget(1)
stack.removeWidget(w)
if w is not None:
w.deleteLater()
self._custom_skill_editors = {}
for path in list_custom_skill_paths(self._game_root()):
stem = path.stem
page = self._custom_skill_editor_page(stem)
self._add_game_skill_page(stem, page)
try:
self._custom_skill_editors[stem].setPlainText(
path.read_text(encoding="utf-8")
)
except Exception as exc:
self._log(f"❌ Could not load skills/{stem}.md: {exc}")
def _reload_one_custom_skill(self, stem: str) -> None:
path = custom_skill_path_for_game(self._game_root(), stem)
ed = self._custom_skill_editors.get(stem)
if ed is None:
return
if not path or not path.is_file():
ed.setPlainText("")
return
try:
ed.setPlainText(path.read_text(encoding="utf-8"))
except Exception as exc:
self._log(f"❌ Could not load skills/{stem}.md: {exc}")
def _save_custom_skill(self, stem: str) -> None:
root = self._game_root_or_warn()
if not root:
return
path = custom_skill_path_for_game(root, stem)
ed = self._custom_skill_editors.get(stem)
if path is None or ed is None:
self._log("❌ Invalid custom skill.")
return
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(ed.toPlainText().rstrip() + "\n", encoding="utf-8")
self._log(f"✅ Saved {path}")
except Exception as exc:
self._log(f"❌ Could not save skills/{stem}.md: {exc}")
def _add_custom_skill(self) -> None:
root = self._game_root_or_warn()
if not root:
return
warn = QMessageBox(self)
warn.setIcon(QMessageBox.Warning)
warn.setWindowTitle("Custom skill - quality warning")
warn.setTextFormat(Qt.RichText)
warn.setText(
"<b>Custom skills are merged into the translation system prompt.</b><br><br>"
"Extra or poorly written skills can <b>distract the model and hurt "
"translation quality</b> (conflicting rules, prompt bloat, diluted quirks).<br><br>"
"Prefer <code>quirks.md</code> for voice rules and <code>game.md</code> "
"for the Translation Frame. Add a custom skill only if you need a rare, tightly scoped "
"overlay - <b>at your own risk</b>."
)
warn.setStandardButtons(QMessageBox.Cancel | QMessageBox.Ok)
warn.button(QMessageBox.Ok).setText("I understand - continue")
warn.setStyleSheet(
"QMessageBox{background-color:#252526;}"
"QLabel{color:#d4d4d4;font-size:13px;min-width:420px;}"
)
if warn.exec_() != QMessageBox.Ok:
return
name, ok = QInputDialog.getText(
self,
"New custom skill",
"Skill file name (letters, numbers, ._- ; saved as <game>/skills/<name>.md):",
)
if not ok:
return
stem = sanitize_custom_skill_stem(name)
if not stem:
QMessageBox.warning(
self,
"Invalid name",
"Use a short name like battle-log or honorifics_extra.\n"
"Reserved: quirks, game, translation.",
)
return
if stem in self._custom_skill_editors:
QMessageBox.information(
self, "Already exists", f"Custom skill '{stem}' is already open."
)
self._select_game_skill_tab(stem)
return
path = custom_skill_path_for_game(root, stem)
if path is None:
return
if path.is_file():
self._reload_custom_skills()
self._select_game_skill_tab(stem)
return
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
f"# {stem}\n\n"
"# Keep this short and specific. Conflicting rules hurt quality.\n",
encoding="utf-8",
)
except Exception as exc:
self._log(f"❌ Could not create skills/{stem}.md: {exc}")
return
page = self._custom_skill_editor_page(stem)
idx = self._add_game_skill_page(stem, page)
self._game_skills_bar.setCurrentIndex(idx)
self._reload_one_custom_skill(stem)
self._log(f"✅ Created custom skill {path}")
def _delete_custom_skill(self, stem: str) -> None:
root = self._game_root_or_warn()
if not root:
return
path = custom_skill_path_for_game(root, stem)
reply = QMessageBox.question(
self,
"Delete custom skill",
f"Delete skills/{stem}.md from the game folder?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if reply != QMessageBox.Yes:
return
try:
if path and path.is_file():
path.unlink()
self._log(f"🗑 Deleted {path}")
except Exception as exc:
self._log(f"❌ Could not delete skills/{stem}.md: {exc}")
return
self._reload_custom_skills()

View file

@ -6,8 +6,8 @@ Mirrors the RPGMaker WorkflowTab, driven by the vendored WolfDawn ``wolf`` CLI
Step 0 Project - select game folder; import wolf_json/ into files/ (like RPGMaker)
Step 1 Pre-process - optional dazedformat + gameupdate/ copy before translating
Step 2 Glossary - build vocab.txt (characters / worldbuilding terms) before
translating so the AI keeps names and voice consistent
Step 2 Setup - Project Setup skill + vocab / quirks / game skills editors
(build glossary and API overlays before translating)
Step 3 Names - translate names.json (Phase 0). WolfDawn safe entries
are translated per-name; refs and verify names are skipped.
Harvests short name terms into vocab.txt.
@ -99,7 +99,8 @@ from util.wolfdawn import db_classify as wolf_db
from util.wolfdawn import wrap_search as wolf_ws
import util.dazedwrap as dazedwrap
from util.paths import PROJECT_ROOT
from gui.setup_skills_editors import SetupSkillsEditors
from util.paths import PROJECT_ROOT, VOCAB_PATH
from util.project_scanner import (
detect_wolf_layout,
find_wolf_text_archives,
@ -111,7 +112,7 @@ from util.project_scanner import (
wolf_repair_nested_data_dir,
wolf_unpack_out_dir,
)
from util.vocab import read_game_vocab, write_game_vocab
from util.skills import load_project_setup
# Workflow-level label for the non-batch (live) translation path. The Translation
# tab's own mode is called "Translate"; _workflow_mode_text() maps to that.
@ -128,93 +129,6 @@ PHASE_NAMES_KINDS = {"names"}
PHASE_DB_KINDS = {"db"}
PHASE_MAPS_EVENTS_KINDS = {"map", "common", "gamedat", "txt", "txt-dir"}
# Glossary-discovery prompt for Copilot / Cursor, tailored to WolfDawn's extracted
# JSON (source/text pairs staged in files/). Produces the same two-section format
# the shared vocab.txt expects, so the AI output pastes straight into the editor.
_WOLF_GLOSSARY_PROMPT = (
"You are an expert Japanese WOLF RPG Editor game analyst building a translation glossary.\n"
"\n"
"<task>\n"
"Extract named characters and lore-specific worldbuilding terms from this game's extracted "
"text. Produce a structured glossary in the exact format specified below. It will be loaded "
"directly into a translation tool, so strict format compliance is required.\n"
"</task>\n"
"\n"
"--- attach the extracted JSON in files/ here before continuing ---\n"
"\n"
"<data_format>\n"
"The files/ folder holds WolfDawn extractions as JSON. Each file is a list of entries with a\n"
"'source' field (the original Japanese) and a 'text' field (initially empty/identical).\n"
"Analyse the 'source' fields only. The files are:\n"
" - DataBase.project.json, CDataBase.project.json, SysDatabase.project.json — databases:\n"
" richest, small source of character/actor names, classes, factions, and lore titles.\n"
" - CommonEvent.dat.json — common events: dialogue and system text.\n"
" - Game.dat.json — game/system strings (title, terms).\n"
" - <Map>.mps.json — per-map events: the main story dialogue (can be large).\n"
" - Evtext.json — external event text, when present.\n"
" - names.json — item/skill/enemy value names (translated separately in Step 3; do NOT list them).\n"
"</data_format>\n"
"\n"
"<file_strategy>\n"
"Map files can be extremely large. Do NOT read them sequentially — you will hit context "
"limits. Instead:\n"
"1. Read the DataBase.project.json files IN FULL first — small and the best source of names.\n"
"2. For large map files, SEARCH (grep) the 'source' fields instead of reading sequentially. "
"Prioritise dialogue because it is the best evidence for character voice:\n"
" - Speaker patterns such as 【Name】, [Name], Name「…」, Name, and <Name> at the start of a line\n"
" - Katakana clusters or kanji compound proper nouns that recur across dialogue\n"
" Scan the lowest-numbered maps first — early maps have the most story content.\n"
"3. Stop once you stop finding new names or terms. Do not pad the output.\n"
"</file_strategy>\n"
"\n"
"<rules>\n"
"Apply to both sections:\n"
"- Separator: use a plain hyphen-minus (-). Never use an em dash or en dash. "
"The translation tool only recognises the plain hyphen.\n"
"- Descriptions must be entirely in English. Refer to other characters by English name only.\n"
"- Never give two spelling options (e.g. 'Sylfia / Sylphia' is wrong). Commit to one translation.\n"
"\n"
"# Game Characters — rules:\n"
"- Discover named characters from the databases and dialogue. Skip unnamed NPCs, generic "
"enemies, and narration-only entries.\n"
"- For each character include: gender, role, speech register, and personality. Note if the "
"name is player-chosen (a hero/actor whose name comes from a variable or input prompt).\n"
"\n"
"# Worldbuilding Terms — rules:\n"
"- Include: faction/organisation names, locations mentioned in dialogue but not on maps, "
"unique magic systems, lore titles, recurring in-universe concepts.\n"
"- Exclude: skill names, item names, weapon/armour names (translated via names.json in "
"Step 3). Skip generic RPG words. Do not repeat character names here.\n"
"</rules>\n"
"\n"
"<output_format>\n"
"Return the ENTIRE glossary inside ONE fenced code block (a ``` block), with nothing before "
"or after it, so it can be copied in a single click and pasted straight into the tool.\n"
"Inside the code block, output EXACTLY two sections with these headers. Do not add any "
"preamble, explanation, or text outside the entries.\n"
"\n"
"# Game Characters\n"
"# Worldbuilding Terms\n"
"\n"
"Entry format: Japanese (English) - description\n"
"\n"
"<example>\n"
"# Game Characters\n"
"シロ (Shiro) - Female; protagonist; player-named; speaks in a flustered, cute register "
"with feminine speech markers\n"
"ゼクス (Zex) - Male; antagonist; cold and commanding; uses an archaic formal register\n"
"カナエ (Kanae) - Female; NPC shopkeeper; warm and motherly; ends sentences with わね\n"
"\n"
"# Worldbuilding Terms\n"
"虚無の穴 (Void Rift) - Dimensional tear referenced repeatedly in late-game dialogue; "
"not a named map location\n"
"裁定者 (Arbiter) - Title held by the ruling council; lore-specific rank with no "
"real-world equivalent\n"
"</example>\n"
"</output_format>\n"
)
# Speaker-format prompt for a repo-aware AI. WolfDawn already detects and tags who
# speaks on each line, so the only decision left is whether its LOW-confidence
# first-line guesses are really speaker names for this game. The AI inspects the
@ -620,7 +534,7 @@ class WolfWorkflowTab(QWidget):
_tab_defs = [
("0 Project", self._build_step0),
("1 Pre-process", self._build_step1_preprocess),
("2 Glossary", self._build_step2_glossary),
("2 Setup", self._build_step2_setup),
("3 Names", self._build_step3_names),
("4 Database", self._build_step4_database),
("5 Maps/Events", self._build_step5_maps_events),
@ -1142,6 +1056,8 @@ class WolfWorkflowTab(QWidget):
self._save_setting("last_game_folder", folder)
self._game_root = folder
if hasattr(self, "setup_editors"):
self.setup_editors.reload_all()
self.detected_label.setText("Scanning…")
self.detected_label.setStyleSheet(
"color:#9d9d9d;font-size:13px;padding:4px 8px;"
@ -1767,80 +1683,97 @@ class WolfWorkflowTab(QWidget):
self._run_task(task, on_done=on_done)
# ── Step 2: Glossary ───────────────────────────────────────────────────────
# ── Step 2: Setup ──────────────────────────────────────────────────────────
def _build_step2_glossary(self, layout: QVBoxLayout):
layout.addWidget(_make_section_label("Step 2 · Glossary (build before translating)"))
def _build_step2_setup(self, layout: QVBoxLayout):
layout.addWidget(_make_section_label("Step 2 · Setup (Project Setup + Skills)"))
layout.addWidget(self._desc(
"vocab.txt is the project-wide glossary used by every translation batch to keep "
"character names, speech register, honorifics, and lore terms consistent. Build it "
"before translating (Steps 4-5) so the AI already knows every character and term. "
"Copy the prompt below into Cursor or Copilot Chat with the extracted files/ JSON "
"open, let it analyse the game, then paste its output into the editor and save."
"Copy Project Setup into Cursor/Copilot with files/ open. Paste glossary into Vocab, "
"quirks into Quirks, game_skill into Game skills. Speakers advice (LOWCONF_FIRSTLINE) "
"is applied via the Step 5 checkbox - keep that step self-contained."
))
prompt_btn = _make_btn("📋 Copy glossary prompt for Copilot / Cursor", "#5a3a7a")
prompt_btn.clicked.connect(self._copy_wolf_glossary_prompt)
layout.addWidget(prompt_btn)
fmt = QLabel(
"Format: Japanese (English) - description\n"
"Example: シロ (Shiro) - Female; protagonist; flustered, cute register"
actions = QHBoxLayout()
actions.setSpacing(8)
copy_btn = _make_btn("📋 Copy Project Setup", "#555")
copy_btn.setFixedWidth(190)
copy_btn.setFixedHeight(32)
copy_btn.setToolTip(
"Clipboard skill for the game repo IDE. Returns glossary, speakers, "
"translation_quirks, and game_skill code blocks."
)
fmt.setFont(QFont("Consolas", 9))
fmt.setWordWrap(True)
fmt.setStyleSheet(
"color:#569cd6;background-color:#1a1e2a;border:1px solid #2a3a5a;"
"padding:5px 10px;border-radius:4px;font-size:12px;border-left:3px solid #2a6a9a;"
)
layout.addWidget(fmt)
self.vocab_editor = QTextEdit()
self.vocab_editor.setMinimumHeight(120)
self.vocab_editor.setFont(QFont("Consolas", 9))
self.vocab_editor.setStyleSheet(
"QTextEdit{background-color:#252526;color:#d4d4d4;border:1px solid #3c3c3c;"
"border-radius:4px;padding:8px;selection-background-color:#264f78;}"
)
self._reload_vocab()
layout.addWidget(self.vocab_editor, 1)
vrow = QHBoxLayout()
save_btn = _make_btn("💾 Save vocab.txt", "#3a7a3a")
save_btn.clicked.connect(self._save_vocab)
vrow.addWidget(save_btn)
reload_btn = _make_btn("↺ Reload", "#555")
reload_btn.clicked.connect(self._reload_vocab)
vrow.addWidget(reload_btn)
vrow.addStretch()
layout.addLayout(vrow)
copy_btn.clicked.connect(self._copy_project_setup_prompt)
actions.addWidget(copy_btn)
actions.addStretch()
layout.addLayout(actions)
note = self._desc(
"You don't need to add item / skill / enemy value names here: Phase 0 translates "
"WolfDawn safe entries from names.json and harvests them into vocab.txt. "
"Foundation DB also merges short labels (map names, titles) after Step 4. "
"Focus this glossary on characters and worldbuilding terms."
"Do not list names.json item/skill/enemy values in the glossary - Phase 0 "
"(Step 3) harvests those. Focus Vocab on characters and worldbuilding."
)
layout.addWidget(note)
def _copy_wolf_glossary_prompt(self):
try:
QApplication.clipboard().setText(_WOLF_GLOSSARY_PROMPT)
self._log("📋 Glossary prompt copied. Paste it into Cursor/Copilot with files/ open.")
except Exception as exc:
self._log(f"❌ Could not copy glossary prompt: {exc}")
self.setup_editors = SetupSkillsEditors(
self,
game_root_fn=lambda: self.folder_edit.text().strip() or self._game_root,
log_fn=self._log,
)
layout.addWidget(self.setup_editors, 1)
self.setup_editors.reload_all()
def _reload_vocab(self):
def _copy_project_setup_prompt(self):
"""Copy the Wolf Project Setup skill, optionally prepending known vocab speakers."""
try:
self.vocab_editor.setPlainText(read_game_vocab())
speakers = self._read_vocab_speakers()
prepend = ""
if speakers:
speaker_lines = "\n".join(f" {orig} ({tl})" for orig, tl in speakers)
prepend = (
"<known_speakers>\n"
"These character names were already listed in vocab.txt.\n"
"For the glossary block '# Game Characters', prefer entries for these names, "
"then cross-check DataBase*.project.json and dialogue for other major names.\n"
"\n"
+ speaker_lines
+ "\n</known_speakers>\n"
)
prompt = load_project_setup("wolf", prepend=prepend)
QApplication.clipboard().setText(prompt)
self._log("📋 Project Setup skill copied. Paste it into Cursor/Copilot with files/ open.")
except Exception as exc:
self._log(f"❌ Could not load vocab.txt: {exc}")
self._log(f"❌ Could not load Project Setup skill: {exc}")
def _save_vocab(self):
def _read_vocab_speakers(self) -> list[tuple[str, str]]:
"""Parse '# Speakers' (or '# Game Characters') from vocab.txt as (orig, tl) pairs."""
vocab_path = VOCAB_PATH
if not vocab_path.exists():
return []
try:
write_game_vocab(self.vocab_editor.toPlainText())
self._log("✅ vocab.txt saved (base terms from vocab_base.txt appended).")
except Exception as exc:
self._log(f"❌ Could not save vocab.txt: {exc}")
content = vocab_path.read_text(encoding="utf-8")
except Exception:
return []
results = []
for header in (r"Speakers", r"Game Characters"):
m = re.search(
rf"^[ ]*#\s*{header}\s*$\r?\n(.*?)(?=^[ ]*#|\Z)",
content,
re.MULTILINE | re.DOTALL,
)
if not m:
continue
for line in m.group(1).splitlines():
line = line.strip()
if not line:
continue
pm = re.match(r"^(.+?)\s+\((.+?)\)", line)
if pm:
pair = (pm.group(1), pm.group(2))
if pair not in results:
results.append(pair)
if results:
break
return results
# ── Step 4: Database ───────────────────────────────────────────────────────
@ -3912,7 +3845,7 @@ class WolfWorkflowTab(QWidget):
short_names = (
"Project",
"Prep",
"Glossary",
"Setup",
"Names",
"Database",
"Maps",

View file

@ -21,17 +21,8 @@ import threading
from pathlib import Path
from util.paths import VOCAB_PATH
from util.skills import (
custom_skill_path_for_game,
game_skill_path_for_game,
list_custom_skill_paths,
load_project_setup,
migrate_game_skill_text,
quirks_path_for_game,
sanitize_custom_skill_stem,
)
from util.skills import load_project_setup
from util.vocab import BASE_SEPARATOR as _SHARED_BASE_SEPARATOR
from util.vocab import read_game_vocab, write_game_vocab
import jsbeautifier
@ -46,7 +37,6 @@ from PyQt5.QtWidgets import (
QGridLayout,
QGroupBox,
QHBoxLayout,
QInputDialog,
QLabel,
QLineEdit,
QListWidget,
@ -57,9 +47,7 @@ from PyQt5.QtWidgets import (
QSizePolicy,
QSpinBox,
QSplitter,
QStackedWidget,
QStyle,
QTabBar,
QTabWidget,
QTextEdit,
QToolButton,
@ -68,6 +56,8 @@ from PyQt5.QtWidgets import (
QAbstractItemView,
)
from gui.setup_skills_editors import SetupSkillsEditors
from gui.translation_tab import (
BATCH_MODE_LABEL,
BATCH_MODE_BENEFIT_NOTE,
@ -622,13 +612,6 @@ def _make_icon_btn(icon_text: str, tooltip: str = "") -> QPushButton:
# Helpers
# ─────────────────────────────────────────────────────────────────────────────
class _PlainPasteTextEdit(QTextEdit):
"""QTextEdit that always pastes as plain text to avoid spurious newlines."""
def insertFromMimeData(self, source): # noqa: N802
self.insertPlainText(source.text())
# ─────────────────────────────────────────────────────────────────────────────
# Main widget
# ─────────────────────────────────────────────────────────────────────────────
@ -1716,153 +1699,13 @@ class WorkflowTab(QWidget):
layout.addWidget(top)
self._populate_speaker_flags()
# ---- Editors (tabbed to save vertical space) -----------------------
editors = QTabWidget()
editors.setObjectName("setupEditors")
editors.setDocumentMode(False)
tab_bar = editors.tabBar()
tab_bar.setExpanding(False)
tab_bar.setMinimumHeight(34)
tab_bar.setStyleSheet(
"QTabBar::tab{background:#333337;color:#c0c0c0;min-width:90px;min-height:28px;"
"padding:6px 16px;border:1px solid #3c3c3c;border-bottom:none;"
"margin-right:3px;font-size:12px;font-weight:bold;}"
"QTabBar::tab:selected{background:#1e1e1e;color:#4ec9b0;"
"border-top:2px solid #4ec9b0;}"
"QTabBar::tab:hover{color:#ffffff;}"
self.setup_editors = SetupSkillsEditors(
self,
game_root_fn=lambda: self.folder_edit.text().strip(),
log_fn=self._log,
)
editors.setStyleSheet(
"QTabWidget::pane{border:1px solid #3c3c3c;background:#1e1e1e;}"
)
_ed_ss = (
"QTextEdit{background-color:#1e1e1e;color:#d4d4d4;"
"border:none;padding:8px;"
"selection-background-color:#264f78;}"
)
def _editor_page(path_hint: str, tip: str, save_slot, reload_slot, attr: str):
page = QWidget()
vl = QVBoxLayout(page)
vl.setContentsMargins(8, 8, 8, 8)
vl.setSpacing(6)
tip_lbl = QLabel(tip)
tip_lbl.setWordWrap(True)
tip_lbl.setStyleSheet("color:#9d9d9d;font-size:12px;")
vl.addWidget(tip_lbl)
path_lbl = QLabel(path_hint)
path_lbl.setStyleSheet("color:#569cd6;font-size:11px;font-family:Consolas,monospace;")
vl.addWidget(path_lbl)
ed = _PlainPasteTextEdit()
ed.setMinimumHeight(160)
ed.setFont(QFont("Consolas", 9))
ed.setStyleSheet(_ed_ss)
setattr(self, attr, ed)
vl.addWidget(ed, 1)
row = QHBoxLayout()
row.setSpacing(8)
save_btn = _make_btn("💾 Save", "#3a7a3a")
save_btn.setFixedWidth(110)
save_btn.clicked.connect(save_slot)
row.addWidget(save_btn)
reload_btn = _make_btn("↺ Reload", "#555")
reload_btn.setFixedWidth(110)
reload_btn.clicked.connect(reload_slot)
row.addWidget(reload_btn)
row.addStretch()
vl.addLayout(row)
return page
editors.addTab(
_editor_page(
"data/vocab.txt (game section)",
"Paste the glossary code block. Format: Japanese (English) - notes",
self._save_vocab,
self._reload_vocab,
"vocab_editor",
),
"Vocab",
)
editors.addTab(
_editor_page(
"<game>/skills/quirks.md",
"Paste the translation_quirks block. Merged onto the system prompt at translate time.",
self._save_quirks,
self._reload_quirks,
"quirks_editor",
),
"Quirks",
)
# Game skills: built-in game.md + optional custom overlays
game_skills_page = QWidget()
gs_layout = QVBoxLayout(game_skills_page)
gs_layout.setContentsMargins(0, 0, 0, 0)
gs_layout.setSpacing(0)
self._custom_skill_editors: dict[str, QTextEdit] = {}
self._game_skills_bar = QTabBar()
self._game_skills_bar.setDocumentMode(True)
self._game_skills_bar.setExpanding(False)
self._game_skills_bar.setDrawBase(False)
self._game_skills_bar.setMinimumHeight(30)
self._game_skills_bar.setStyleSheet(
"QTabBar::tab{background:#2d2d30;color:#9d9d9d;padding:6px 12px;"
"border:1px solid #3c3c3c;border-bottom:none;margin-right:2px;}"
"QTabBar::tab:selected{background:#1e1e1e;color:#e0e0e0;}"
"QTabBar::tab:hover{color:#ffffff;}"
)
self._game_skills_stack = QStackedWidget()
self._game_skills_stack.setStyleSheet("background:#1e1e1e;")
self._game_skills_bar.currentChanged.connect(self._game_skills_stack.setCurrentIndex)
strip = QWidget()
strip.setStyleSheet("background-color:#252526;")
strip_l = QHBoxLayout(strip)
strip_l.setContentsMargins(8, 6, 10, 0)
strip_l.setSpacing(6)
strip_l.addWidget(self._game_skills_bar, 0, Qt.AlignBottom)
add_btn = QPushButton("+ Add custom")
add_btn.setCursor(Qt.PointingHandCursor)
add_btn.setFixedHeight(30)
add_btn.setToolTip(
"Create a custom skills/*.md overlay.\n"
"Merged into the translation system prompt - can hurt quality."
)
add_btn.setStyleSheet(
"QPushButton{background-color:#2d2d30;color:#4ec9b0;"
"border:1px solid #3c3c3c;border-bottom:none;"
"border-top-left-radius:3px;border-top-right-radius:3px;"
"padding:0 12px;font-size:12px;font-weight:bold;}"
"QPushButton:hover{background-color:#3a3a3a;color:#7ee0c8;"
"border-color:#4ec9b0;}"
"QPushButton:pressed{background-color:#1e1e1e;}"
)
add_btn.clicked.connect(self._add_custom_skill)
strip_l.addWidget(add_btn, 0, Qt.AlignBottom)
strip_l.addStretch(1)
gs_layout.addWidget(strip)
gs_layout.addWidget(self._game_skills_stack, 1)
self._add_game_skill_page(
"game",
_editor_page(
"<game>/skills/game.md",
"Paste the game_skill Translation Frame. Merged into the translation "
"system prompt (before quirks) when this game folder is selected.",
self._save_game_skill,
self._reload_game_skill,
"game_skill_editor",
),
)
editors.addTab(game_skills_page, "Game skills")
layout.addWidget(editors, 1)
self._reload_vocab()
self._reload_quirks()
self._reload_game_skill()
self._reload_custom_skills()
layout.addWidget(self.setup_editors, 1)
self.setup_editors.reload_all()
def _run_parse_speakers(self):
@ -3196,9 +3039,8 @@ class WorkflowTab(QWidget):
return
self._save_setting("last_game_folder", folder)
self._reload_quirks()
self._reload_game_skill()
self._reload_custom_skills()
if hasattr(self, "setup_editors"):
self.setup_editors.reload_all()
self.detected_label.setText("Scanning…")
self.detected_label.setStyleSheet(
"color:#9d9d9d;font-size:13px;padding:4px 8px;"
@ -3556,292 +3398,6 @@ class WorkflowTab(QWidget):
except Exception as exc:
self._log(f"❌ Could not load Project Setup skill: {exc}")
def _game_root_or_warn(self) -> str | None:
root = self.folder_edit.text().strip()
if not root:
self._log("⚠ Select a game folder in Step 0 first.")
return None
return root
def _reload_quirks(self):
root = self.folder_edit.text().strip()
path = quirks_path_for_game(root)
if not path or not path.is_file():
if hasattr(self, "quirks_editor"):
self.quirks_editor.setPlainText("")
return
try:
self.quirks_editor.setPlainText(path.read_text(encoding="utf-8"))
except Exception as exc:
self._log(f"❌ Could not load skills/quirks.md: {exc}")
def _save_quirks(self):
root = self._game_root_or_warn()
if not root:
return
path = quirks_path_for_game(root)
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(self.quirks_editor.toPlainText().rstrip() + "\n", encoding="utf-8")
self._log(f"✅ Saved {path}")
except Exception as exc:
self._log(f"❌ Could not save skills/quirks.md: {exc}")
def _reload_game_skill(self):
root = self.folder_edit.text().strip()
path = game_skill_path_for_game(root)
if not path or not path.is_file():
if hasattr(self, "game_skill_editor"):
self.game_skill_editor.setPlainText("")
return
try:
raw = path.read_text(encoding="utf-8")
fixed = migrate_game_skill_text(raw)
self.game_skill_editor.setPlainText(fixed)
if fixed != raw:
path.write_text(fixed.rstrip() + "\n", encoding="utf-8")
self._log(
f"↺ Updated legacy quirk path in {path.name} → skills/quirks.md"
)
except Exception as exc:
self._log(f"❌ Could not load game skill: {exc}")
def _save_game_skill(self):
root = self._game_root_or_warn()
if not root:
return
path = game_skill_path_for_game(root)
try:
path.parent.mkdir(parents=True, exist_ok=True)
text = migrate_game_skill_text(self.game_skill_editor.toPlainText())
self.game_skill_editor.setPlainText(text)
path.write_text(text.rstrip() + "\n", encoding="utf-8")
self._log(f"✅ Saved {path}")
except Exception as exc:
self._log(f"❌ Could not save game skill: {exc}")
def _add_game_skill_page(self, title: str, page: QWidget) -> int:
"""Add a page to the Game skills bar + stack. Returns the new index."""
idx = self._game_skills_stack.addWidget(page)
self._game_skills_bar.addTab(title)
return idx
def _select_game_skill_tab(self, title: str) -> None:
for i in range(self._game_skills_bar.count()):
if self._game_skills_bar.tabText(i) == title:
self._game_skills_bar.setCurrentIndex(i)
return
def _custom_skill_editor_page(self, stem: str) -> QWidget:
"""Editor page for one custom skill file under <game>/skills/<stem>.md."""
page = QWidget()
vl = QVBoxLayout(page)
vl.setContentsMargins(8, 8, 8, 8)
vl.setSpacing(6)
tip = QLabel(
"Custom skill - merged into the translation system prompt. "
"Extra or conflicting rules can hurt quality."
)
tip.setWordWrap(True)
tip.setStyleSheet("color:#c9a227;font-size:12px;")
vl.addWidget(tip)
path_lbl = QLabel(f"<game>/skills/{stem}.md")
path_lbl.setStyleSheet(
"color:#569cd6;font-size:11px;font-family:Consolas,monospace;"
)
vl.addWidget(path_lbl)
ed = _PlainPasteTextEdit()
ed.setMinimumHeight(160)
ed.setFont(QFont("Consolas", 9))
ed.setStyleSheet(
"QTextEdit{background-color:#1e1e1e;color:#d4d4d4;"
"border:none;padding:8px;"
"selection-background-color:#264f78;}"
)
self._custom_skill_editors[stem] = ed
vl.addWidget(ed, 1)
row = QHBoxLayout()
row.setSpacing(8)
save_btn = _make_btn("💾 Save", "#3a7a3a")
save_btn.setFixedWidth(110)
save_btn.clicked.connect(lambda _=False, s=stem: self._save_custom_skill(s))
row.addWidget(save_btn)
reload_btn = _make_btn("↺ Reload", "#555")
reload_btn.setFixedWidth(110)
reload_btn.clicked.connect(lambda _=False, s=stem: self._reload_one_custom_skill(s))
row.addWidget(reload_btn)
delete_btn = _make_btn("🗑 Delete", "#8b0000")
delete_btn.setFixedWidth(110)
delete_btn.setToolTip("Delete this custom skill file from the game folder")
delete_btn.clicked.connect(lambda _=False, s=stem: self._delete_custom_skill(s))
row.addWidget(delete_btn)
row.addStretch()
vl.addLayout(row)
return page
def _reload_custom_skills(self):
"""Rebuild custom skill tabs from <game>/skills/*.md (excluding built-ins)."""
bar = getattr(self, "_game_skills_bar", None)
stack = getattr(self, "_game_skills_stack", None)
if bar is None or stack is None:
return
# Remove existing custom tabs (index 0 is always game.md).
while bar.count() > 1:
bar.removeTab(1)
while stack.count() > 1:
w = stack.widget(1)
stack.removeWidget(w)
if w is not None:
w.deleteLater()
self._custom_skill_editors = {}
root = self.folder_edit.text().strip()
for path in list_custom_skill_paths(root):
stem = path.stem
page = self._custom_skill_editor_page(stem)
self._add_game_skill_page(stem, page)
try:
self._custom_skill_editors[stem].setPlainText(
path.read_text(encoding="utf-8")
)
except Exception as exc:
self._log(f"❌ Could not load skills/{stem}.md: {exc}")
def _reload_one_custom_skill(self, stem: str):
root = self.folder_edit.text().strip()
path = custom_skill_path_for_game(root, stem)
ed = self._custom_skill_editors.get(stem)
if ed is None:
return
if not path or not path.is_file():
ed.setPlainText("")
return
try:
ed.setPlainText(path.read_text(encoding="utf-8"))
except Exception as exc:
self._log(f"❌ Could not load skills/{stem}.md: {exc}")
def _save_custom_skill(self, stem: str):
root = self._game_root_or_warn()
if not root:
return
path = custom_skill_path_for_game(root, stem)
ed = self._custom_skill_editors.get(stem)
if path is None or ed is None:
self._log("❌ Invalid custom skill.")
return
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(ed.toPlainText().rstrip() + "\n", encoding="utf-8")
self._log(f"✅ Saved {path}")
except Exception as exc:
self._log(f"❌ Could not save skills/{stem}.md: {exc}")
def _add_custom_skill(self):
root = self._game_root_or_warn()
if not root:
return
warn = QMessageBox(self)
warn.setIcon(QMessageBox.Warning)
warn.setWindowTitle("Custom skill - quality warning")
warn.setTextFormat(Qt.RichText)
warn.setText(
"<b>Custom skills are merged into the translation system prompt.</b><br><br>"
"Extra or poorly written skills can <b>distract the model and hurt "
"translation quality</b> (conflicting rules, prompt bloat, diluted quirks).<br><br>"
"Prefer <code>quirks.md</code> for voice rules and <code>game.md</code> "
"for the Translation Frame. Add a custom skill only if you need a rare, tightly scoped "
"overlay - <b>at your own risk</b>."
)
warn.setStandardButtons(QMessageBox.Cancel | QMessageBox.Ok)
warn.button(QMessageBox.Ok).setText("I understand - continue")
warn.setStyleSheet(
"QMessageBox{background-color:#252526;}"
"QLabel{color:#d4d4d4;font-size:13px;min-width:420px;}"
)
if warn.exec_() != QMessageBox.Ok:
return
name, ok = QInputDialog.getText(
self,
"New custom skill",
"Skill file name (letters, numbers, ._- ; saved as <game>/skills/<name>.md):",
)
if not ok:
return
stem = sanitize_custom_skill_stem(name)
if not stem:
QMessageBox.warning(
self,
"Invalid name",
"Use a short name like battle-log or honorifics_extra.\n"
"Reserved: quirks, game, translation.",
)
return
if stem in self._custom_skill_editors:
QMessageBox.information(
self, "Already exists", f"Custom skill '{stem}' is already open."
)
self._select_game_skill_tab(stem)
return
path = custom_skill_path_for_game(root, stem)
if path is None:
return
if path.is_file():
# File exists on disk but tab missing - just reload tabs.
self._reload_custom_skills()
self._select_game_skill_tab(stem)
return
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
f"# {stem}\n\n"
"# Keep this short and specific. Conflicting rules hurt quality.\n",
encoding="utf-8",
)
except Exception as exc:
self._log(f"❌ Could not create skills/{stem}.md: {exc}")
return
page = self._custom_skill_editor_page(stem)
idx = self._add_game_skill_page(stem, page)
self._game_skills_bar.setCurrentIndex(idx)
self._reload_one_custom_skill(stem)
self._log(f"✅ Created custom skill {path}")
def _delete_custom_skill(self, stem: str):
root = self._game_root_or_warn()
if not root:
return
path = custom_skill_path_for_game(root, stem)
reply = QMessageBox.question(
self,
"Delete custom skill",
f"Delete skills/{stem}.md from the game folder?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if reply != QMessageBox.Yes:
return
try:
if path and path.is_file():
path.unlink()
self._log(f"🗑 Deleted {path}")
except Exception as exc:
self._log(f"❌ Could not delete skills/{stem}.md: {exc}")
return
self._reload_custom_skills()
def _read_vocab_speakers(self) -> list[tuple[str, str]]:
"""Parse the '# Speakers' section from vocab.txt and return (orig, tl) pairs."""
vocab_path = VOCAB_PATH
@ -3873,23 +3429,6 @@ class WorkflowTab(QWidget):
results.append((pm.group(1), pm.group(2)))
return results
def _reload_vocab(self):
try:
self.vocab_editor.setPlainText(read_game_vocab())
except Exception as exc:
self._log(f"❌ Could not load vocab.txt: {exc}")
def _save_vocab(self):
try:
write_game_vocab(self.vocab_editor.toPlainText())
self._log("✅ vocab.txt saved (base terms from vocab_base.txt appended).")
except Exception as exc:
self._log(f"❌ Could not save vocab.txt: {exc}")
# ─────────────────────────────────────────────────────────────────────────
# Step 2 Actor substitution
# ─────────────────────────────────────────────────────────────────────────
# ─────────────────────────────────────────────────────────────────────────
# Step 3 Speaker detection
# ─────────────────────────────────────────────────────────────────────────