Files
AIGame/Tools/validate_skill_design.py
T

123 lines
9.3 KiB
Python

"""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<len(item['prompt'])<=2000,'SFX prompt must be specific English within service limit')
require(1<=item.get('duration',sound['defaults']['duration'])<=30,'TangoFlux duration must be 1..30 seconds')
require(type(item['seed']) is int,'Reproducible SFX seed required')
production=item.get('production')
if production:
output=root/production['output'];receipt=root/production['receipt']
require(output.is_file() and receipt.is_file(),'Generated SFX output/receipt missing')
require(hashlib.sha256(output.read_bytes()).hexdigest()==production['sha256'],'Generated SFX hash mismatch')
rows=json.loads(receipt.read_text(encoding='utf-8'))['items']
require(any(r['id']==item['id'] and r['outputSha256']==production['sha256'] and r['job']['status']=='succeeded' for r in rows),'Generated SFX receipt mismatch')
generated+=1
expected='planned_not_generated' if generated==0 else 'generated' if generated==len(items) else 'partially_generated'
require(sound['status']==expected,'SFX production status does not match receipts')
for asset in assets.values():
if asset['type']=='sfx':require(PurePosixPath(asset['path']).stem in items,'SFX production recipe missing: '+asset['id'])
return dict(status='design_contract_validated',runtimeIntegrated=False,generatedAudio=generated==len(items),generatedSfxCount=generated,
skills=len(skills),pets=len(gameplay['pets']),enemies=len(gameplay['enemies']),stages=len(gameplay['stages']),
presentationProfiles=len(profiles),existingAssets=sum(a['status']=='existing' for a in assets.values()),
plannedAssets=sum(a['status']=='planned' for a in assets.values()),sfxRecipes=len(items),
note='Design proposal validation only; formal runtime integration is validated separately by validate_skill_runtime.py')
def load(path):return json.loads(path.read_text(encoding='utf-8-sig'))
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--report',type=Path,default=Path('Tools/output/skill_system_design/validation.json'))
args=parser.parse_args()
result=validate(load(ROOT/'Config/Combat/skill_system_v1.proposal.json'),load(ROOT/'Config/Combat/skill_presentations_v1.proposal.json'),
load(ROOT/'Doc/Design/Sound/BattleSkills/manifest.json'),load(ROOT/'Config/Gameplay/greenfield.json'))
target=args.report.resolve();require(target.is_relative_to(ROOT),'Report must stay in repository')
target.parent.mkdir(parents=True,exist_ok=True);target.write_text(json.dumps(result,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
print(json.dumps(result,ensure_ascii=False))
if __name__=='__main__':main()