From 57379076bd5d4b0d9913e5293541c94e7077769a Mon Sep 17 00:00:00 2001 From: DazedAnon Date: Sun, 26 Jul 2026 15:29:24 -0500 Subject: [PATCH] feat(release): add public release ZIP creation functionality - Introduced a new button to create a player-ready ZIP of the complete game, excluding translator workspaces, version control files, documentation, backups, saves, and playtest plugins. - Implemented a background worker to handle ZIP creation without blocking the GUI. - Added user feedback for the completion and size of the generated ZIP file. --- gui/wolf_workflow_tab.py | 50 +++++ gui/workflow_tab.py | 107 ++++++++++- tests/test_release_package.py | 179 ++++++++++++++++++ util/release_package.py | 344 ++++++++++++++++++++++++++++++++++ 4 files changed, 679 insertions(+), 1 deletion(-) create mode 100644 tests/test_release_package.py create mode 100644 util/release_package.py diff --git a/gui/wolf_workflow_tab.py b/gui/wolf_workflow_tab.py index a42c6b7..2be7abd 100644 --- a/gui/wolf_workflow_tab.py +++ b/gui/wolf_workflow_tab.py @@ -4283,6 +4283,19 @@ class WolfWorkflowTab(QWidget): "archive's encryption where possible (--like the backed-up original)." )) + layout.addWidget(_make_hr()) + layout.addWidget(self._subheading("Public release")) + layout.addWidget(self._desc( + "Create a player-ready ZIP of the complete game. Translator workspaces, version " + "control, documentation, backups, saves, and other tool artifacts are omitted; " + "GameUpdate files remain available to players. The game folder is not modified." + )) + self._release_zip_btn = self._register( + _make_btn("📦 Create Public Release ZIP", "#007acc") + ) + self._release_zip_btn.clicked.connect(self._create_public_release) + layout.addWidget(self._release_zip_btn) + layout.addWidget(_make_hr()) layout.addWidget(self._subheading("Update existing saves (optional)")) layout.addWidget(self._desc( @@ -4356,6 +4369,43 @@ class WolfWorkflowTab(QWidget): self._run_task(task) + def _create_public_release(self): + if not self._require_root(): + return + + from util.release_package import default_release_zip_path + + suggested = str(default_release_zip_path(self._game_root)) + output, _ = QFileDialog.getSaveFileName( + self, + "Create Public Release ZIP", + suggested, + "ZIP archives (*.zip)", + ) + if not output: + return + + game_root = self._game_root + + def task(log, progress=None): + from util.release_package import create_release_zip + + log(f"Creating public release ZIP from {game_root} …") + result = create_release_zip(game_root, output, progress=progress) + size_mb = result.output_path.stat().st_size / (1024 * 1024) + log( + f" Added {result.files_added} file(s), omitted " + f"{result.excluded_entries} tool/private item(s)." + ) + if result.sanitized_plugin_lists: + log(" Removed playtest plugins from the archived plugins.js.") + return True, ( + f"Created {result.output_path.name} ({size_mb:.1f} MB) at " + f"{result.output_path}." + ) + + self._run_task(task) + def _browse_saves(self): start = self.save_edit.text() or (str(Path(self._game_root) / "Save") if self._game_root else str(Path.home())) folder = QFileDialog.getExistingDirectory(self, "Select Save Folder", start) diff --git a/gui/workflow_tab.py b/gui/workflow_tab.py index e1bbc75..f2b4ea7 100644 --- a/gui/workflow_tab.py +++ b/gui/workflow_tab.py @@ -371,6 +371,34 @@ class _FileCopyWorker(QThread): self.done.emit(copied, errors) +class _ReleaseZipWorker(QThread): + """Build a sanitized public-release ZIP without blocking the GUI.""" + + done = pyqtSignal(object) + error = pyqtSignal(str) + progress = pyqtSignal(int, int, str) + + def __init__(self, game_root: str, output_path: str): + super().__init__() + self.game_root = game_root + self.output_path = output_path + + def run(self): + try: + from util.release_package import create_release_zip + + result = create_release_zip( + self.game_root, + self.output_path, + progress=lambda current, total, label: self.progress.emit( + current, total, label + ), + ) + self.done.emit(result) + except Exception as exc: + self.error.emit(str(exc)) + + class _JsFormatWorker(QThread): """Format a JavaScript file using jsbeautifier (pure Python, no Node required).""" done = pyqtSignal(bool, str) @@ -495,7 +523,10 @@ _STEP_HELP: dict[int, str] = { "Export - copy finished translations from translated/ back " "into the game's data folder:
" "• Export Active Files - only names currently in files/
" - "• Export ALL - everything under translated/" + "• Export ALL - everything under translated/

" + "Create Public Release ZIP packages the complete game beside its folder while " + "omitting translator workspaces, VCS metadata, documentation, backups, saves, and " + "playtest plugins. GameUpdate files are kept." ), 6: ( "Step 6 - Images (MV/MZ)

" @@ -746,6 +777,7 @@ class WorkflowTab(QWidget): self._current_step_index: int = 0 self._last_import_signature: tuple[str, ...] | None = None self._pending_import_signature: tuple[str, ...] | None = None + self._release_zip_btn: QPushButton | None = None self._init_ui() @@ -2191,6 +2223,16 @@ class WorkflowTab(QWidget): export_all_btn.clicked.connect(self._export_to_game) inner.addLayout(_labeled_row(export_lbl, export_active_btn, export_all_btn)) + + release_lbl = QLabel("Release") + self._release_zip_btn = _make_btn("📦 Create Public Release ZIP", "#007acc") + self._release_zip_btn.setToolTip( + "Archive the detected game folder for players. Excludes DazedTL workspaces, " + "version-control files, documentation, backups, saves, and playtest plugins; " + "keeps GameUpdate files. The source game folder is not changed." + ) + self._release_zip_btn.clicked.connect(self._create_public_release) + inner.addLayout(_labeled_row(release_lbl, self._release_zip_btn)) layout.addWidget(box, 0, Qt.AlignLeft) # ── Step 6: Editable images ───────────────────────────────────────────── @@ -4115,6 +4157,69 @@ class WorkflowTab(QWidget): self._worker = w w.start() + def _create_public_release(self): + game_root = self.folder_edit.text().strip() + if not game_root or not Path(game_root).is_dir(): + QMessageBox.warning( + self, + "No game folder", + "Select and detect a game folder in Step 0 first.", + ) + return + if self._worker is not None and self._worker.isRunning(): + QMessageBox.information(self, "Busy", "A task is already running. Please wait.") + return + + from util.release_package import default_release_zip_path + + suggested = str(default_release_zip_path(game_root)) + output, _ = QFileDialog.getSaveFileName( + self, + "Create Public Release ZIP", + suggested, + "ZIP archives (*.zip)", + ) + if not output: + return + + self._log(f"Creating public release ZIP from {game_root} …") + worker = _ReleaseZipWorker(game_root, output) + self._worker = worker + if self._release_zip_btn is not None: + self._release_zip_btn.setEnabled(False) + + def finished(): + if self._worker is worker: + self._worker = None + if self._release_zip_btn is not None: + self._release_zip_btn.setEnabled(True) + + def failed(message: str): + self._log(f"❌ Public release ZIP: {message}") + QMessageBox.warning(self, "Release ZIP failed", message) + + worker.done.connect(self._on_public_release_done) + worker.error.connect(failed) + worker.finished.connect(finished) + worker.finished.connect(worker.deleteLater) + worker.start() + + def _on_public_release_done(self, result): + size_mb = result.output_path.stat().st_size / (1024 * 1024) + message = ( + f"Created {result.output_path.name} ({size_mb:.1f} MB) with " + f"{result.files_added} file(s); omitted {result.excluded_entries} " + "tool/private item(s)." + ) + if result.sanitized_plugin_lists: + message += " Removed playtest plugins from the archived plugins.js." + self._log(f"✅ {message}") + QMessageBox.information( + self, + "Public Release ZIP created", + f"{message}\n\nSaved to:\n{result.output_path}", + ) + def _resolve_export_path(self) -> str | None: """Return the game data path, prompting if not yet set.""" game_data = self._data_path diff --git a/tests/test_release_package.py b/tests/test_release_package.py new file mode 100644 index 0000000..b76ce11 --- /dev/null +++ b/tests/test_release_package.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +"""Tests for player-ready public release archives and workflow buttons.""" + +from __future__ import annotations + +import tempfile +import unittest +import zipfile +from pathlib import Path +from unittest.mock import patch + +from PyQt5.QtCore import QSettings +from PyQt5.QtWidgets import QApplication + +from util.release_package import ReleasePackageError, create_release_zip + + +class ReleasePackageTests(unittest.TestCase): + def _write(self, root: Path, relative: str, content: bytes = b"content") -> Path: + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(content) + return path + + def test_release_keeps_game_and_updater_but_omits_tool_artifacts(self): + with tempfile.TemporaryDirectory() as raw: + base = Path(raw) + game = base / "Translated Game" + game.mkdir() + + self._write(game, "Game.exe", b"game") + self._write(game, "Data.wolf", b"translated archive") + self._write(game, "Data.wolf.bak", b"original backup") + self._write(game, "Data/manual.txt", b"runtime text") + self._write(game, "Save01.sav", b"translator save") + self._write(game, "Save/Slot1.rpgsave", b"translator save") + self._write(game, ".git/config") + self._write(game, ".dazedtl/images/menu.png") + self._write(game, "wolf_json/manifest.json") + self._write(game, "ace_json/Actors.json") + self._write(game, "scripts/release.py") + self._write(game, "docs/translation-notes.md") + self._write(game, "skills/game.md") + self._write(game, "vocab.txt") + self._write(game, "README.md") + self._write(game, ".gitignore") + self._write(game, ".env", b"key=secret") + + self._write(game, "GameUpdate.bat", b"launcher") + self._write(game, "GameUpdate_linux.sh", b"launcher") + self._write(game, "UberWolfCli.exe", b"wolf updater") + self._write(game, "UberWolfCli.LICENSE.txt", b"license") + self._write(game, "gameupdate/patch.ps1", b"patch") + self._write(game, "gameupdate/patch-config.txt", b"repo=game") + self._write(game, "gameupdate/previous_patch_sha.txt", b"private state") + + plugins_js = b"""var $plugins = +[ + {"name":"CorePlugin","status":true,"parameters":{"label":"keep"}}, + {"name":"TLInspector","status":true,"parameters":{}}, + {"name":"Forge_MZ","status":true,"parameters":{}} +]; +""" + source_plugins = self._write(game, "js/plugins.js", plugins_js) + self._write(game, "js/plugins/CorePlugin.js", b"core") + self._write(game, "js/plugins/TLInspector.js", b"inspector") + self._write(game, "js/plugins/Forge_MZ.js", b"forge") + + output = base / "release.zip" + progress = [] + result = create_release_zip( + game, + output, + progress=lambda current, total, label: progress.append( + (current, total, label) + ), + ) + + prefix = "Translated Game/" + with zipfile.ZipFile(output) as archive: + names = set(archive.namelist()) + archived_plugins = archive.read(prefix + "js/plugins.js").decode("utf-8") + + for relative in ( + "Game.exe", + "Data.wolf", + "Data/manual.txt", + "GameUpdate.bat", + "GameUpdate_linux.sh", + "UberWolfCli.exe", + "UberWolfCli.LICENSE.txt", + "gameupdate/patch.ps1", + "gameupdate/patch-config.txt", + "js/plugins/CorePlugin.js", + ): + self.assertIn(prefix + relative, names) + + for relative in ( + "Data.wolf.bak", + "Save01.sav", + "Save/Slot1.rpgsave", + ".git/config", + ".dazedtl/images/menu.png", + "wolf_json/manifest.json", + "ace_json/Actors.json", + "scripts/release.py", + "docs/translation-notes.md", + "skills/game.md", + "vocab.txt", + "README.md", + ".gitignore", + ".env", + "gameupdate/previous_patch_sha.txt", + "js/plugins/TLInspector.js", + "js/plugins/Forge_MZ.js", + ): + self.assertNotIn(prefix + relative, names) + + self.assertIn('"name":"CorePlugin"', archived_plugins) + self.assertNotIn("TLInspector", archived_plugins) + self.assertNotIn("Forge_MZ", archived_plugins) + self.assertEqual(source_plugins.read_bytes(), plugins_js) + self.assertEqual(result.output_path, output.resolve()) + self.assertEqual(result.sanitized_plugin_lists, 1) + self.assertEqual(progress[-1][:2], (result.files_added, result.files_added)) + + def test_release_path_must_be_outside_game_folder(self): + with tempfile.TemporaryDirectory() as raw: + game = Path(raw) / "Game" + game.mkdir() + self._write(game, "Game.exe") + output = game / "release.zip" + + with self.assertRaisesRegex(ReleasePackageError, "outside the game folder"): + create_release_zip(game, output) + + self.assertFalse(output.exists()) + + def test_zip_suffix_is_added(self): + with tempfile.TemporaryDirectory() as raw: + base = Path(raw) + game = base / "Game" + game.mkdir() + self._write(game, "Game.exe") + + result = create_release_zip(game, base / "release") + + self.assertEqual(result.output_path.name, "release.zip") + self.assertTrue(result.output_path.is_file()) + + +class ReleaseWorkflowButtonTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.app = QApplication.instance() or QApplication([]) + + def test_both_workflows_expose_public_release_button(self): + from gui.workflow_tab import WorkflowTab + from gui.wolf_workflow_tab import WolfWorkflowTab + + with tempfile.TemporaryDirectory() as raw: + settings = QSettings(str(Path(raw) / "settings.ini"), QSettings.IniFormat) + with ( + patch("gui.workflow_tab.QSettings", return_value=settings), + patch("gui.wolf_workflow_tab.QSettings", return_value=settings), + ): + rpg = WorkflowTab() + wolf = WolfWorkflowTab() + try: + self.assertEqual(rpg._release_zip_btn.text(), "📦 Create Public Release ZIP") + self.assertEqual(wolf._release_zip_btn.text(), "📦 Create Public Release ZIP") + self.assertIn("GameUpdate", rpg._release_zip_btn.toolTip()) + finally: + rpg.close() + wolf.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/util/release_package.py b/util/release_package.py new file mode 100644 index 0000000..3c2af03 --- /dev/null +++ b/util/release_package.py @@ -0,0 +1,344 @@ +"""Build public game archives without translator and playtest artifacts.""" + +from __future__ import annotations + +import codecs +import os +import re +import tempfile +import zipfile +from dataclasses import dataclass +from pathlib import Path +from typing import Callable + + +ProgressCallback = Callable[[int, int, str], None] + + +class ReleasePackageError(RuntimeError): + """Raised when a safe public-release archive cannot be produced.""" + + +@dataclass(frozen=True) +class ReleasePackageResult: + output_path: Path + files_added: int + bytes_added: int + excluded_entries: int + sanitized_plugin_lists: int + + +# These are never game payloads and should be ignored wherever they occur. +_EXCLUDED_DIR_NAMES = frozenset( + { + ".dazedtl", + ".git", + ".hg", + ".svn", + "__pycache__", + ".pytest_cache", + ".mypy_cache", + ".ruff_cache", + } +) + +# DazedTL creates these at the game root while translating or playtesting. +_EXCLUDED_ROOT_DIR_NAMES = frozenset( + { + ".agents", + ".claude", + ".codex", + ".cursor", + ".github", + ".gitlab", + ".idea", + ".vscode", + "ace_json", + "docs", + "documentation", + "files", + "log", + "logs", + "save", + "saves", + "scripts", + "skills", + "test", + "tests", + "translated", + "wolf_json", + } +) + +_EXCLUDED_ROOT_FILE_NAMES = frozenset( + { + ".editorconfig", + ".gitattributes", + ".gitignore", + ".pre-commit-config.yaml", + "readme.md", + "requirements.txt", + "todo.md", + "translation_quirks.txt", + "vocab.txt", + } +) + +_EXCLUDED_FILE_NAMES = frozenset( + { + ".ds_store", + ".editorconfig", + ".gitattributes", + ".gitignore", + "desktop.ini", + "forge_mv.bat", + "forge_mv.js", + "forge_mz.bat", + "forge_mz.js", + "patch2.ps1", + "patch2.sh", + "previous_patch_sha.txt", + "thumbs.db", + "tlinspector.bat", + "tlinspector.js", + } +) + +_UPDATER_ROOT_FILE_NAMES = frozenset( + { + "gameupdate.bat", + "gameupdate_linux.sh", + "uberwolfcli.exe", + "uberwolfcli.license.txt", + } +) + +_ROOT_DOCUMENT_SUFFIXES = frozenset({".doc", ".docx", ".markdown", ".md", ".pdf", ".rst"}) +_BACKUP_SUFFIXES = (".bak", ".backup", ".orig", ".tmp") +_PLUGIN_LIST_PATHS = frozenset({"js/plugins.js", "www/js/plugins.js"}) +_PLAYTEST_PLUGIN_NAMES = ("TLInspector", "Forge_MV", "Forge_MZ") + + +def default_release_zip_path(game_root: str | Path) -> Path: + """Return a release path beside *game_root*, never inside it.""" + root = Path(game_root).expanduser().resolve() + name = root.name or "game" + return root.parent / f"{name}-public.zip" + + +def normalize_release_zip_path(output_path: str | Path) -> Path: + output = Path(output_path).expanduser() + if output.suffix.casefold() != ".zip": + output = output.with_name(output.name + ".zip") + return output.resolve() + + +def _is_updater_path(relative: Path) -> bool: + parts = tuple(part.casefold() for part in relative.parts) + if not parts: + return False + if parts[0] == "gameupdate": + return True + return len(parts) == 1 and parts[0] in _UPDATER_ROOT_FILE_NAMES + + +def exclusion_reason(relative: str | Path, *, is_dir: bool = False) -> str | None: + """Return why a game-relative path is omitted, or ``None`` when it ships.""" + relative = Path(relative) + parts = tuple(part.casefold() for part in relative.parts) + if not parts: + return None + + if any(part in _EXCLUDED_DIR_NAMES for part in parts): + return "tool metadata or cache" + + name = parts[-1] + if name.startswith(".env"): + return "private environment configuration" + if name in _EXCLUDED_FILE_NAMES: + return "tool-generated or temporary file" + + # Player updater files are the explicit exception to translator-artifact + # filtering. VCS/cache directories and updater runtime state were handled above. + if _is_updater_path(relative): + return None + + if len(parts) == 1: + if is_dir and name in _EXCLUDED_ROOT_DIR_NAMES: + return "translator workspace" + if not is_dir: + if name in _EXCLUDED_ROOT_FILE_NAMES: + return "translator configuration or documentation" + if relative.suffix.casefold() in _ROOT_DOCUMENT_SUFFIXES: + return "root documentation file" + if name.startswith("save") or relative.suffix.casefold() in {".rpgsave", ".sav"}: + return "local save data" + + if not is_dir and name.endswith(_BACKUP_SUFFIXES): + return "backup or temporary file" + + return None + + +def _iter_release_files(root: Path) -> tuple[list[tuple[Path, Path]], int]: + files: list[tuple[Path, Path]] = [] + excluded = 0 + for current, dir_names, file_names in os.walk(root, topdown=True, followlinks=False): + current_path = Path(current) + kept_dirs: list[str] = [] + for name in sorted(dir_names, key=str.casefold): + path = current_path / name + relative = path.relative_to(root) + if path.is_symlink() or exclusion_reason(relative, is_dir=True): + excluded += 1 + else: + kept_dirs.append(name) + dir_names[:] = kept_dirs + + for name in sorted(file_names, key=str.casefold): + path = current_path / name + relative = path.relative_to(root) + if path.is_symlink() or exclusion_reason(relative): + excluded += 1 + continue + files.append((path, relative)) + return files, excluded + + +def _remove_plugin_object(content: str, plugin_name: str) -> str: + """Remove one object from RPG Maker's JavaScript plugin-array syntax.""" + marker = re.search(rf'"name"\s*:\s*"{re.escape(plugin_name)}"', content) + while marker: + start = content.rfind("{", 0, marker.start()) + if start < 0: + raise ReleasePackageError(f"Could not remove {plugin_name} from plugins.js") + depth = 0 + end = None + in_string = False + escaped = False + for index in range(start, len(content)): + char = content[index] + if in_string: + if escaped: + escaped = False + elif char == "\\": + escaped = True + elif char == '"': + in_string = False + continue + if char == '"': + in_string = True + elif char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + end = index + 1 + break + if end is None: + raise ReleasePackageError(f"Could not parse {plugin_name} in plugins.js") + + before = content[:start].rstrip() + after = content[end:].lstrip() + if after.startswith(","): + after = after[1:].lstrip() + elif before.endswith(","): + before = before[:-1].rstrip() + separator = "\n" if before and after and not before.endswith("\n") else "" + content = before + separator + after + marker = re.search(rf'"name"\s*:\s*"{re.escape(plugin_name)}"', content) + return re.sub(r",(\s*)\];", r"\1];", content) + + +def sanitize_plugins_js(raw: bytes) -> tuple[bytes, bool]: + """Remove DazedTL playtest entries while preserving the source file's BOM.""" + had_bom = raw.startswith(codecs.BOM_UTF8) + try: + content = raw.decode("utf-8-sig") + except UnicodeDecodeError as exc: + raise ReleasePackageError(f"plugins.js is not valid UTF-8: {exc}") from exc + original = content + for plugin_name in _PLAYTEST_PLUGIN_NAMES: + content = _remove_plugin_object(content, plugin_name) + if content == original: + return raw, False + encoded = content.encode("utf-8") + return (codecs.BOM_UTF8 + encoded if had_bom else encoded), True + + +def _archive_name(root: Path, relative: Path) -> str: + return (Path(root.name or "game") / relative).as_posix() + + +def create_release_zip( + game_root: str | Path, + output_path: str | Path, + *, + progress: ProgressCallback | None = None, +) -> ReleasePackageResult: + """Create an atomic public-release ZIP and leave the source game untouched.""" + root = Path(game_root).expanduser().resolve() + if not root.is_dir(): + raise ReleasePackageError(f"Game folder not found: {root}") + if root.parent == root: + raise ReleasePackageError("A filesystem root cannot be packaged as a game folder") + + output = normalize_release_zip_path(output_path) + try: + output.relative_to(root) + except ValueError: + pass + else: + raise ReleasePackageError("Save the public ZIP outside the game folder") + if output.exists() and output.is_dir(): + raise ReleasePackageError(f"Release path is a directory: {output}") + + output.parent.mkdir(parents=True, exist_ok=True) + files, excluded = _iter_release_files(root) + total = len(files) + if not total: + raise ReleasePackageError("No releasable game files were found") + + descriptor, temporary_name = tempfile.mkstemp( + prefix=f".{output.name}.", suffix=".tmp", dir=output.parent + ) + os.close(descriptor) + temporary = Path(temporary_name) + files_added = 0 + bytes_added = 0 + sanitized = 0 + try: + with zipfile.ZipFile( + temporary, + mode="w", + compression=zipfile.ZIP_DEFLATED, + compresslevel=6, + allowZip64=True, + ) as archive: + for index, (source, relative) in enumerate(files, start=1): + if progress: + progress(index, total, relative.as_posix()) + arcname = _archive_name(root, relative) + if relative.as_posix().casefold() in _PLUGIN_LIST_PATHS: + raw = source.read_bytes() + payload, changed = sanitize_plugins_js(raw) + info = zipfile.ZipInfo.from_file(source, arcname=arcname) + archive.writestr(info, payload, compress_type=zipfile.ZIP_DEFLATED) + sanitized += int(changed) + bytes_added += len(payload) + else: + archive.write(source, arcname=arcname) + bytes_added += source.stat().st_size + files_added += 1 + os.replace(temporary, output) + except Exception: + temporary.unlink(missing_ok=True) + raise + + return ReleasePackageResult( + output_path=output, + files_added=files_added, + bytes_added=bytes_added, + excluded_entries=excluded, + sanitized_plugin_lists=sanitized, + )