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.
This commit is contained in:
DazedAnon 2026-07-26 15:29:24 -05:00
parent 02671c5c24
commit 57379076bd
4 changed files with 679 additions and 1 deletions

View file

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

View file

@ -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] = {
"<b>Export</b> - copy finished translations from <code>translated/</code> back "
"into the game's data folder:<br>"
"• <b>Export Active Files</b> - only names currently in <code>files/</code><br>"
"• <b>Export ALL</b> - everything under <code>translated/</code>"
"• <b>Export ALL</b> - everything under <code>translated/</code><br><br>"
"<b>Create Public Release ZIP</b> packages the complete game beside its folder while "
"omitting translator workspaces, VCS metadata, documentation, backups, saves, and "
"playtest plugins. GameUpdate files are kept."
),
6: (
"<b>Step 6 - Images (MV/MZ)</b><br><br>"
@ -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

View file

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

344
util/release_package.py Normal file
View file

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