"""Validate proposed battle skill contracts without touching the running game.""" from __future__ import annotations import argparse import hashlib from decimal import Decimal,ROUND_CEILING import json from pathlib import Path,PurePosixPath ROOT=Path(__file__).resolve().parents[1] EVENTS={'CastStarted','CastReleased','CastEnded','CastCancelled','ProjectileSpawned', 'ProjectileExpired','HitResolved','StatusApplied','StatusRemoved','UnitDied','BattleEnded'} def require(condition,message): if not condition:raise ValueError(message) def index(items,label): result={item['id']:item for item in items} require(len(result)==len(items),f'Duplicate {label} id') return result def ticks(seconds,tick_ms): return int((Decimal(str(seconds))*1000/Decimal(tick_ms)).to_integral_value(rounding=ROUND_CEILING)) def validate(plan,view,sound,gameplay,root=ROOT): for item in (plan,view): require(item['schemaVersion']==1 and item['status']=='design_only' and item['runtimeEnabled'] is False, 'Design examples must stay explicitly disabled until runtime integration') require(plan['source']['path']=='Config/Gameplay/greenfield.json','Keep one gameplay value source') require(plan['clock']['tickMs']==50 and plan['projectile']['collisionAuthority']=='simulation','Invalid simulation contract') require(plan['scheduling']['priorities']['ability']>plan['scheduling']['priorities']['basic'],'Ability must win arbitration') templates=index(plan['templates'],'template');profiles=index(view['profiles'],'profile');assets=index(view['assets'],'asset') for template in templates.values(): actions=index(template['actions'],'logical action') require(bool(actions),'A skill needs at least one logical action') for action in actions.values(): require(action['trigger']=='delivery_hit' and action['type'] in ('damage','heal','apply_status'),'Unsupported logical action') if action['type']=='damage':require(action.get('amount')=='attack_scaled' and action.get('scale') in ('one','ability_value'),'Damage parameters missing') elif action['type']=='heal':require(action.get('amount')=='ability_value','Heal parameters missing') else: require(action.get('statusId') in {s['id'] for s in plan['statuses']},'Unresolved status') require(action.get('value')=='ability_value' and action.get('duration')=='ability_duration','Status parameters missing') skills=index(plan['skills'],'skill');anchors=set(view['anchors']) require(view['anchorPolicy']['localForward']=='+Z' and view['anchorPolicy']['worldFacing']=='source_to_target', 'Local model forward must be +Z and world facing must aim at target') expected={f"pet.{p['id']}.{slot}" for p in gameplay['pets'] for slot in ('basic','ability')} expected|={f"enemy.{e['id']}.basic" for e in gameplay['enemies']} require(set(skills)==expected,'Skill coverage must include every pet basic/ability and enemy basic') for asset in assets.values(): path=PurePosixPath(asset['path']) require(asset['path'].startswith('Assets/Bundles/') and '..' not in path.parts and '\\' not in asset['path'],'Unsafe asset path') require(asset['type'] in ('animation','vfx','sfx') and asset['status'] in ('existing','planned'),'Invalid asset readiness/type') if asset['status']=='existing':require((root/'AIClient'/str(path)).is_file(),'Asset marked existing is missing: '+asset['id']) for profile in profiles.values(): require(set(profile['bindings'])<=EVENTS,'Unknown presentation event') require(profile['seekReplaysOneShots'] is False,'Seek must not replay historical one-shots') require(all(c.get('when')=='valid_hit' for c in profile['bindings'].get('HitResolved',[])),'Invalid hits must not play hit cues') for cues in profile['bindings'].values(): for cue in cues: if cue['kind']=='stop_token':require(bool(cue['token']),'Missing owned token');continue require(cue['kind'] in ('animation','vfx','sfx'),'Unknown cue kind') require(cue['asset'] in assets,'Unresolved presentation asset: '+cue['asset']) require(assets[cue['asset']]['type']==cue['kind'] and cue['anchor'] in anchors,'Cue type/anchor mismatch') if cue['kind']=='animation':require(cue['rootMotion'] is False,'Animation must not move logical actor') for skill in skills.values(): kind=skill['actorKind'];actor_id=skill['actorId'];slot=skill['slot'] require(kind in ('pet','enemy') and slot in ('basic','ability'),'Unknown actor/slot') actors=gameplay['pets' if kind=='pet' else 'enemies'];actor=next((a for a in actors if a['id']==actor_id),None) require(actor is not None,'Unknown actor') require(skill['sourcePointer']==f"/{'pets' if kind=='pet' else 'enemies'}/{actor_id}",'Source pointer must match actor') require(skill['template'] in templates and skill['presentation'] in profiles,'Unresolved skill reference') t=templates[skill['template']] expected_template=('enemy_melee' if kind=='enemy' else ('basic_ranged' if actor['range']>2 else 'basic_melee')) if slot=='basic' else 'ability_'+actor['ability']['kind'] require(t['id']==expected_template,'Skill template changes actor semantics: '+skill['id']) require(t['delivery'] in ('instant','projectile'),'Unsupported delivery') timing=skill.get('timing',t['timing']);release=timing['releaseTicks'];recovery=timing['recoveryTicks'] require(type(release) is int and type(recovery) is int and release>=0 and recovery>0,'Invalid action timing') cooldown=actor['cooldown'] if slot=='basic' else actor['ability']['cooldown'] require(release+recovery<=ticks(cooldown,50),'Action occupies longer than cooldown: '+skill['id']) for cue in profiles[skill['presentation']]['bindings'].get('CastStarted',[]): if cue['kind']=='animation': mark=assets[cue['asset']]['releaseNormalized'] require(abs(mark-release/(release+recovery))<=1/(release+recovery),'Animation release marker is not aligned: '+skill['id']) require(sound['status'] in ('planned_not_generated','partially_generated','generated'),'Invalid SFX production status') require(1<=sound['maxInflight']<=4,'TangoFlux in-flight limit exceeds guide') require(sound['sourceFormat']==dict(container='wav',sampleRate=44100,channels=2),'TangoFlux source format mismatch') items=index(sound['items'],'SFX') generated=0 for item in items.values(): require(item['prompt'].isascii() and 0