Files
fighting-game-packs/update.py
T

454 lines
15 KiB
Python

from __future__ import annotations
import argparse
import json
import logging
import re
import unicodedata
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Optional
REGISTRY_FILENAME = "registry.json"
IMAGE_EXTENSIONS = (".png", ".jpg", ".jpeg", ".webp", ".avif")
logging.basicConfig(level=logging.INFO, format="%(message)s")
logger = logging.getLogger("pack-maintenance")
@dataclass(frozen=True)
class PackPaths:
pack_dir: Path
manifest_path: Path
characters_dir: Path
# ─────────────────────────────────────────────────────────────
# Generic helpers
# ─────────────────────────────────────────────────────────────
def load_json(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8"))
def save_json(path: Path, data: dict, dry_run: bool = False) -> None:
if dry_run:
logger.info(f"[DRY RUN] Would write {path.name}")
return
temp_path = path.with_suffix(path.suffix + ".tmp")
temp_path.write_text(
json.dumps(data, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
temp_path.replace(path)
def format_bytes(size: int) -> str:
return f"{size / 1024:.1f} KB"
def get_root() -> Path:
return Path(__file__).parent.resolve()
def get_registry_path(root: Path) -> Path:
return root / REGISTRY_FILENAME
def normalize(text: str) -> str:
text = Path(text).stem.lower().strip()
text = unicodedata.normalize("NFKD", text)
text = text.encode("ascii", "ignore").decode("ascii")
return re.sub(r"[^a-z0-9]", "", text)
# ─────────────────────────────────────────────────────────────
# Pack / manifest helpers
# ─────────────────────────────────────────────────────────────
def get_pack_paths(root: Path, pack: dict) -> PackPaths:
pack_dir = root / pack["id"]
return PackPaths(
pack_dir=pack_dir,
manifest_path=pack_dir / "manifest.json",
characters_dir=pack_dir / "characters",
)
def validate_pack_paths(manifest_path: Path, characters_dir: Path) -> bool:
if not manifest_path.exists():
logger.error("❌ manifest.json not found")
return False
if not characters_dir.exists():
logger.error("❌ characters folder not found")
return False
return True
def get_image_files(characters_dir: Path) -> list[Path]:
return [
file
for file in characters_dir.iterdir()
if file.is_file() and file.suffix.lower() in IMAGE_EXTENSIONS
]
def find_character_image(characters_dir: Path, slug: str) -> Optional[Path]:
for ext in IMAGE_EXTENSIONS:
candidate = characters_dir / f"{slug}{ext}"
if candidate.exists():
return candidate
return None
def validate_manifest_characters(characters: list[dict]) -> None:
seen_slugs: set[str] = set()
for character in characters:
slug = character.get("slug")
name = character.get("name")
if not slug:
logger.warning("⚠ Character without slug found")
continue
if not name:
logger.warning(f"⚠ Character without name: {slug}")
if slug in seen_slugs:
logger.warning(f"⚠ Duplicate slug detected: {slug}")
seen_slugs.add(slug)
def build_character_lookup(characters: list[dict]) -> dict[str, str]:
lookup: dict[str, str] = {}
for character in characters:
name = character.get("name")
slug = character.get("slug")
if not name or not slug:
continue
lookup[normalize(name)] = slug
return lookup
# ─────────────────────────────────────────────────────────────
# Pack selection
# ─────────────────────────────────────────────────────────────
def select_pack(packs: list[dict]) -> Optional[dict]:
print("\nAvailable packs:\n")
for index, pack in enumerate(packs, start=1):
print(f"{index}. {pack.get('name', 'Unnamed')} ({pack.get('id', 'no-id')})")
raw = input("\nSelect pack number: ").strip()
try:
selected_index = int(raw) - 1
except ValueError:
logger.error("❌ Invalid number")
return None
if selected_index < 0 or selected_index >= len(packs):
logger.error("❌ Invalid selection")
return None
return packs[selected_index]
# ─────────────────────────────────────────────────────────────
# Manifest writer
# ─────────────────────────────────────────────────────────────
def ordered_character_payload(character: dict) -> dict:
ordered: dict = {}
for key in ("name", "slug", "dlc", "sizeBytes"):
if key in character:
ordered[key] = character[key]
for key, value in character.items():
if key not in ordered:
ordered[key] = value
return ordered
def write_manifest(manifest_path: Path, manifest: dict, dry_run: bool = False) -> None:
manifest_copy = dict(manifest)
characters = manifest_copy.pop("characters", [])
base_json = json.dumps(
manifest_copy,
indent=2,
ensure_ascii=False,
)
if base_json.endswith("\n}"):
base_json = base_json[:-2]
else:
base_json = base_json[:-1]
if characters:
character_lines = [
" " + json.dumps(
ordered_character_payload(character),
ensure_ascii=False,
)
for character in characters
]
characters_json = (
' "characters": [\n'
+ ",\n".join(character_lines)
+ "\n ]\n}"
)
else:
characters_json = ' "characters": []\n}'
final_content = base_json + ",\n" + characters_json + "\n"
if dry_run:
logger.info(f"[DRY RUN] Would update manifest: {manifest_path.name}")
return
temp_path = manifest_path.with_suffix(".tmp")
temp_path.write_text(final_content, encoding="utf-8")
temp_path.replace(manifest_path)
# ─────────────────────────────────────────────────────────────
# Action 1: rename images to slug
# ─────────────────────────────────────────────────────────────
def rename_images(pack_dir: Path, dry_run: bool = False) -> None:
manifest_path = pack_dir / "manifest.json"
characters_dir = pack_dir / "characters"
if not validate_pack_paths(manifest_path, characters_dir):
return
manifest = load_json(manifest_path)
characters = manifest.get("characters", [])
validate_manifest_characters(characters)
lookup = build_character_lookup(characters)
image_files = get_image_files(characters_dir)
if not image_files:
logger.error("❌ No images found")
return
print(f'\nScanning "{manifest.get("name", pack_dir.name)}"...\n')
renamed_count = 0
for image_file in image_files:
normalized_filename = normalize(image_file.name)
matched_slug: Optional[str] = None
if normalized_filename in lookup:
matched_slug = lookup[normalized_filename]
else:
matches = [
slug
for normalized_name, slug in lookup.items()
if normalized_name in normalized_filename or normalized_filename in normalized_name
]
if len(matches) == 1:
matched_slug = matches[0]
elif len(matches) > 1:
logger.warning(f"⚠ Multiple matches for {image_file.name}")
continue
if matched_slug is None:
logger.warning(f"⚠ No character match for {image_file.name}")
continue
new_filename = matched_slug + image_file.suffix.lower()
new_path = characters_dir / new_filename
if image_file.name.lower() == new_filename.lower():
if image_file.name != new_filename:
if dry_run:
logger.info(f"[DRY RUN] {image_file.name} -> {new_filename}")
else:
image_file.rename(new_path)
logger.info(f"{image_file.name} -> {new_filename}")
renamed_count += 1
else:
logger.info(f"{image_file.name}")
continue
if new_path.exists():
logger.warning(f"⚠ Target already exists: {new_filename}")
continue
if dry_run:
logger.info(f"[DRY RUN] {image_file.name} -> {new_filename}")
else:
image_file.rename(new_path)
renamed_count += 1
logger.info(f"{image_file.name} -> {new_filename}")
print("\n✅ Done!\n")
print(f"Renamed: {renamed_count}")
# ─────────────────────────────────────────────────────────────
# Action 2: update sizeBytes and registry totals
# ─────────────────────────────────────────────────────────────
def update_pack_sizes(root: Path, registry: dict, selected_pack: dict, dry_run: bool = False) -> None:
paths = get_pack_paths(root, selected_pack)
if not validate_pack_paths(paths.manifest_path, paths.characters_dir):
return
manifest = load_json(paths.manifest_path)
characters = manifest.get("characters", [])
print(f'\nUpdating "{manifest.get("name", paths.pack_dir.name)}"...\n')
total_size_bytes = 0
valid_character_count = 0
for character in characters:
slug = character.get("slug")
if not slug:
character["sizeBytes"] = 0
logger.warning("⚠ Character without slug found")
continue
image_path = find_character_image(paths.characters_dir, slug)
if image_path is None:
character["sizeBytes"] = 0
logger.warning(f"⚠ Missing image for {slug}")
continue
size = image_path.stat().st_size
character["sizeBytes"] = size
total_size_bytes += size
valid_character_count += 1
logger.info(f"{slug} -> {format_bytes(size)}")
registry_pack = next(
(pack for pack in registry.get("packs", []) if pack.get("id") == manifest.get("id")),
None,
)
if registry_pack is None:
logger.error("❌ Pack not found in registry")
return
registry_pack["totalSizeBytes"] = total_size_bytes
registry_pack["characterCount"] = valid_character_count
registry["updatedAt"] = datetime.now().strftime("%Y-%m-%d")
write_manifest(paths.manifest_path, manifest, dry_run=dry_run)
save_json(root / REGISTRY_FILENAME, registry, dry_run=dry_run)
print("\n✅ Done!\n")
print(f"Characters: {valid_character_count}")
print(f"Total size: {format_bytes(total_size_bytes)} ({total_size_bytes} bytes)")
# ─────────────────────────────────────────────────────────────
# Orchestration
# ─────────────────────────────────────────────────────────────
def load_registry(root: Path) -> Optional[dict]:
registry_path = get_registry_path(root)
if not registry_path.exists():
logger.error("❌ registry.json not found")
return None
return load_json(registry_path)
def get_selected_pack_from_registry(registry: dict) -> Optional[dict]:
packs = registry.get("packs", [])
if not packs:
logger.error("❌ No packs found in registry")
return None
return select_pack(packs)
def process_pack(action: str, root: Path, registry: dict, selected_pack: dict, dry_run: bool) -> None:
paths = get_pack_paths(root, selected_pack)
if action == "rename":
rename_images(paths.pack_dir, dry_run=dry_run)
elif action == "sizes":
update_pack_sizes(root, registry, selected_pack, dry_run=dry_run)
elif action == "both":
rename_images(paths.pack_dir, dry_run=dry_run)
update_pack_sizes(root, registry, selected_pack, dry_run=dry_run)
else:
raise ValueError(f"Unknown action: {action}")
def run(action: str, dry_run: bool = False, process_all: bool = False) -> None:
root = get_root()
registry = load_registry(root)
if registry is None:
return
packs = registry.get("packs", [])
if process_all:
print(f"\nProcessing {len(packs)} pack(s)...")
for pack in packs:
print("\n────────────────────────────")
print(f"{pack.get('name', 'Unnamed')} ({pack.get('id', 'no-id')})")
print("────────────────────────────")
process_pack(action, root, registry, pack, dry_run)
return
selected_pack = get_selected_pack_from_registry(registry)
if selected_pack is None:
return
process_pack(action, root, registry, selected_pack, dry_run)
# ─────────────────────────────────────────────────────────────
# CLI
# ─────────────────────────────────────────────────────────────
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Pack maintenance tool")
parser.add_argument(
"action",
choices=("rename", "sizes", "both"),
nargs="?",
default="both",
help="What to run",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Preview changes without writing files",
)
parser.add_argument(
"--all",
action="store_true",
help="Process all packs",
)
return parser.parse_args()
def main() -> None:
args = parse_args()
run(action=args.action, dry_run=args.dry_run, process_all=args.all)
if __name__ == "__main__":
main()