#!/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: tooling/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 import sys ASSETS_ROOT = os.path.join(os.path.dirname(__file__), "..", "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 main() -> None: manifest = build_manifest() output = json.dumps(manifest, indent=2) + "\n" with open(MANIFEST_PATH, "w", encoding="utf-8") as f: f.write(output) print(f"Written: {MANIFEST_PATH}") print(f" body_types : {len(manifest['body_types'])}") print(f" heads : {len(manifest['heads'])}") print(f" hair : {len(manifest['hair'])}") print(f" facial_hair: {len(manifest['facial_hair'])}") print(f" eyebrows : {len(manifest['eyebrows'])}") print(f" clothing : {len(manifest['clothing'])}") print(f" accessories: {len(manifest['accessories'])}") if __name__ == "__main__": main()