#!/usr/bin/env python3 """Generate client/assets/characters/manifest.json from the asset directories. Run this whenever artists add new assets so the manifest stays in sync: reach generate character-manifest The manifest is the single source of truth for CharacterCreation asset IDs. DirAccess.open() cannot enumerate res:// paths in exported PCK builds (#720). Output: client/assets/characters/manifest.json """ import json import os from tooling.core import config, console # config.repo_root(), not __file__-relative: this file moved two directories # deeper, and a relative root would resolve to nothing and report an empty # manifest as success (the failure that bit validate-checklist in T-1282). ASSETS_ROOT = str(config.path("client", "assets", "characters")) MANIFEST_PATH = os.path.join(ASSETS_ROOT, "manifest.json") def scan_glb_ids(dir_path: str) -> list[str]: """Return sorted list of .glb basenames (without extension) in dir_path.""" if not os.path.isdir(dir_path): return [] ids = sorted( os.path.splitext(f)[0] for f in os.listdir(dir_path) if f.endswith(".glb") and not f.startswith(".") ) return ids def scan_subdirs(dir_path: str) -> list[str]: """Return sorted list of subdirectory names in dir_path.""" if not os.path.isdir(dir_path): return [] return sorted( d for d in os.listdir(dir_path) if os.path.isdir(os.path.join(dir_path, d)) and not d.startswith(".") ) def infer_clothing_slot(item_id: str) -> str: """Infer clothing slot from item_id by convention.""" id_lower = item_id.lower() if any(k in id_lower for k in ("boot", "shoe", "sandal", "slipper")): return "feet" if any(k in id_lower for k in ("pant", "trouser", "skirt", "short")): return "legs" if any(k in id_lower for k in ("glove", "gauntlet")): return "hands" # Default: torso (jacket, tunic, shirt, coveralls, vest, etc.) return "torso" def build_manifest() -> dict: # Body types: subdirs under bodies/ body_types = scan_subdirs(os.path.join(ASSETS_ROOT, "bodies")) # Heads: .glb files in heads/templates/ heads = scan_glb_ids(os.path.join(ASSETS_ROOT, "heads", "templates")) # Hair: .glb files in hair/ hair = scan_glb_ids(os.path.join(ASSETS_ROOT, "hair")) # Facial hair: .glb files in facial_hair/ facial_hair = scan_glb_ids(os.path.join(ASSETS_ROOT, "facial_hair")) # Eyebrows: .glb files in eyebrows/ eyebrows = scan_glb_ids(os.path.join(ASSETS_ROOT, "eyebrows")) # Clothing: subdirs under clothing/, each assigned a slot clothing_items = scan_subdirs(os.path.join(ASSETS_ROOT, "clothing")) clothing: dict = {} for item_id in clothing_items: clothing[item_id] = {"slot": infer_clothing_slot(item_id)} # Accessories: no directory yet — leave empty accessories: list = [] acc_dir = os.path.join(ASSETS_ROOT, "accessories") if os.path.isdir(acc_dir): accessories = scan_glb_ids(acc_dir) return { "body_types": body_types, "heads": heads, "hair": hair, "facial_hair": facial_hair, "eyebrows": eyebrows, "clothing": clothing, "accessories": accessories, } def run() -> None: """Rebuild the manifest from the asset directories and report the counts.""" manifest = build_manifest() output = json.dumps(manifest, indent=2) + "\n" with open(MANIFEST_PATH, "w", encoding="utf-8") as f: f.write(output) console.event(f"Written: {MANIFEST_PATH}") for key in ( "body_types", "heads", "hair", "facial_hair", "eyebrows", "clothing", "accessories", ): console.event(f" {key:<12}: {len(manifest[key])}") console.verdict( f"generate-character-manifest: OK — {sum(len(v) for v in manifest.values())} entries" )