- Eliminated the removal of playtest plugins from the archived plugins.js during ZIP creation. - Updated documentation to reflect changes in the exclusion of playtest plugins and other tool artifacts. - Adjusted tests to ensure that the plugins.js file retains all installed plugins, including TLInspector and Forge_MZ.
262 lines
7.5 KiB
Python
262 lines
7.5 KiB
Python
"""Build public game archives without translator and playtest artifacts."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
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
|
|
|
|
|
|
# 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",
|
|
"patch2.ps1",
|
|
"patch2.sh",
|
|
"previous_patch_sha.txt",
|
|
"thumbs.db",
|
|
}
|
|
)
|
|
|
|
_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")
|
|
|
|
|
|
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 _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
|
|
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)
|
|
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,
|
|
)
|