"""Recipe-driven character art production: same rig -> three clips -> visual prefab -> QA. Default is local cached production. --generate explicitly requests DGXA inference. Unity-managed assets are imported/authored only through Unity Pipeline. """ from __future__ import annotations import argparse import hashlib import json import os from pathlib import Path import re import shutil import subprocess import sys import time REPO=Path(__file__).resolve().parents[1] PROJECT=REPO/'AIClient' def sha(path): return hashlib.sha256(Path(path).read_bytes()).hexdigest() def resolve(path): result=(REPO/path).resolve() if not result.is_relative_to(REPO): raise ValueError(f'Path leaves repository: {path}') return result def recipe(path): r=json.loads(path.read_text(encoding='utf-8-sig')) if r.get('schemaVersion')!=1 or not re.fullmatch(r'[A-Za-z][A-Za-z0-9_]*',r['id']): raise ValueError('Unsupported schema / invalid actor id') if r['profile'] not in ('biped_short','avian_grounded','quadruped_turtle','quadruped_heavy'): raise ValueError('Unsupported anatomy. Add and validate a different anatomy adapter before using it.') r.setdefault('usage','enemy_visual') if r['usage'] not in ('enemy_visual','companion_visual'): raise ValueError('Unsupported character usage') r.setdefault('visualPrefab',r.get('enemyPrefab','')) r.setdefault('attackStyle','body_slam') if r['attackStyle'] not in ('body_slam','ranged_cast','wing_cast','turtle_bubble','braced_bash','flame_roar'): raise ValueError('Unsupported attack style') if (r['profile']=='avian_grounded') != (r['attackStyle']=='wing_cast'): raise ValueError('Grounded birds require wing_cast; wing_cast requires avian_grounded') if (r['profile']=='quadruped_turtle') != (r['attackStyle']=='turtle_bubble'): raise ValueError('Turtles require their own four-legged bubble adapter') if (r['profile']=='quadruped_heavy') != (r['attackStyle']=='braced_bash'): raise ValueError('Heavy quadrupeds require their own braced bash adapter') r.setdefault('attackEvent','OnCompanionAttackImpact' if r['usage']=='companion_visual' else 'OnEnemyAttackImpact') if not re.fullmatch(r'[A-Za-z][A-Za-z0-9_]*',r['attackEvent']): raise ValueError('Invalid attack event') if r.get('boneMappingReady') is False: raise ValueError('Inspect this character\'s generated rig and complete its bone mapping before production') if r['attackStyle']=='flame_roar': required=('rootBone','chestBone','neckBone','headBone','rightArm','leftArm','rightPawTip','leftPawTip', 'rightFoot','leftFoot','rightFootTip','leftFootTip','faceRight','faceLeft') if r['profile']!='biped_short' or any(not re.fullmatch(r'bone_\d+',r.get(k,'')) for k in required): raise ValueError('Flame roar requires this biped\'s own head, neck, two arms/paws and feet') for right,left in (('rightArm','leftArm'),('rightPawTip','leftPawTip'),('rightFoot','leftFoot'),('rightFootTip','leftFootTip'),('faceRight','faceLeft'),('headBone','chestBone')): if r[right]==r[left]:raise ValueError('Flame roar requires independent head/body and left/right mappings') lo,hi=r.get('maneRegionMin',[]),r.get('maneRegionMax',[]) if len(lo)!=3 or len(hi)!=3 or any(a>=b for a,b in zip(lo,hi)) or not 0=b for a,b in zip(lo,hi)) or not 090s). server_ms=int(os.environ.get('AIGAME_RUN_SCRIPT_TIMEOUT_MS','90000')) args.extend(['--timeout',str(server_ms//1000+30)]) kwargs.setdefault('timeout_ms',server_ms) args.append(command) for k,v in kwargs.items(): args.extend(['--'+k, json.dumps(v,ensure_ascii=False) if isinstance(v,(list,dict,bool)) else str(v)]) proc=subprocess.run(args,cwd=REPO,capture_output=True,text=True,encoding='utf-8',errors='replace',timeout=int(os.environ.get('AIGAME_RUN_SCRIPT_TIMEOUT_MS','90000'))//1000+90) if proc.returncode: raise RuntimeError(proc.stderr or proc.stdout) data=json.loads(proc.stdout) result=data.get('data',{}).get('result') if not data.get('success') or isinstance(result,dict) and result.get('success') is False: raise RuntimeError(json.dumps(data,ensure_ascii=False)) return result def script(file,entry,args): result=unity('run_script',file='AgentScripts/'+file+'.cs',entry=entry,args=args) print(result.get('result',result),flush=True) return result def wait_editor(play_mode): deadline=time.monotonic()+60 while time.monotonic()